From 6ec54d6ef0c5da90b2cef0b7fdb78cd7f91e576d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:37:07 -0300 Subject: [PATCH 01/18] feat(mchlogcore): add Transport and optional Closer interfaces Define a Transport strategy interface so V1, V2 and the upcoming V3 (network) share one contract for the methods they all implement (LogSubject and GetFileNameFromStreamName). Resource cleanup lives in a separate optional Closer interface. Backends that hold resources requiring explicit release (e.g. UDP sockets in V3) implement Close; backends that rely on process exit (V1, V2) do not need to be modified. Both interfaces are declared in mchlogcore. Backends satisfy them implicitly via Go structural typing, avoiding any import cycle. --- mchlogcore/transport.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 mchlogcore/transport.go diff --git a/mchlogcore/transport.go b/mchlogcore/transport.go new file mode 100644 index 0000000..57173a4 --- /dev/null +++ b/mchlogcore/transport.go @@ -0,0 +1,30 @@ +package mchlogcore + +// Transport é a estratégia que efetivamente persiste ou envia os logs. +// Cada backend (arquivo V1, arquivo V2, rede V3, ...) implementa esta +// interface e é selecionado pelo facade através de SetVersion. +// +// A interface é declarada aqui no pacote do facade. Os pacotes de backend +// não precisam importá-la: como Go usa interfaces estruturais, basta que +// os métodos coincidam. Isso evita ciclo de import entre mchlogcore e os +// pacotes mchlogcoreV*. +type Transport interface { + // LogSubject persiste/envia o conteúdo de log. + // Mesma assinatura usada pelos backends V1 e V2. + LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) + + // GetFileNameFromStreamName devolve o caminho do arquivo (V1/V2) + // ou um descritor lógico (ex.: "udp://host:port/") para + // transportes de rede. Mantida por compatibilidade com testes existentes. + GetFileNameFromStreamName(subject string) string +} + +// Closer é uma interface opcional: backends que mantêm recursos +// que precisam ser liberados explicitamente (ex.: conexões de rede) +// implementam Close. O facade chama via type assertion, então +// backends que não precisam (V1, V2) não são forçados a implementar. +type Closer interface { + // Close libera recursos do transporte. + // Deve ser idempotente. + Close() error +} From 1727d9c46fcbf759f74f2933451d3c4a747d7a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:39:23 -0300 Subject: [PATCH 02/18] refactor(mchlogcore): dispatch via Transport interface Replace the if/else dispatch in LogType with a package-level current Transport populated by SetVersion. Adding a new version (V3 network) now means extending one switch in transportFor and one in InitializeMchLog instead of touching every method. GetIP keeps its V1-only semantics via type assertion against *mchlogcorev1.LogType. The init log message ("MchLogToolkit initialized version=...") is preserved. Adds a Close method on the facade that delegates to the active transport only when it implements the optional Closer interface (future V3 will). For V1 and V2 it is a no-op, so the existing file backends are not modified. Behavior is unchanged for existing services that do not call SetVersion: V1 stays the default. All previous tests stay green. New tests in mchlogcore lock in: - SetVersion(V1) selects *mchlogcorev1.LogType - SetVersion(V2) selects *mchlogcorev2.LogType - GetIP returns "" when not running V1 - Close is a no-op for V1 and V2 (no Closer impl) --- mchlogcore/mchlog.go | 91 +++++++++++++++++++++++++++------------ mchlogcore/mchlog_test.go | 57 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 mchlogcore/mchlog_test.go diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index 16e32e8..7c74361 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -5,66 +5,101 @@ import ( "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" ) -// LogVersion is a type to define which version of the logger to use +// Asserções de tempo de compilação garantindo que cada backend +// satisfaz a interface Transport. +var ( + _ Transport = (*mchlogcorev1.LogType)(nil) + _ Transport = (*mchlogcorev2.LogType)(nil) +) + +// LogVersion identifica qual backend de log está em uso. type LogVersion int const ( - // V1 refers to the first version of the logger, which includes IP in filename and uses a channel-based approach + // V1 — backend de arquivo, formato com IP e timestamp por hora. V1 LogVersion = iota - // V2 refers to the second version of the logger, which has a simpler file structure without IP or timestamps in names + // V2 — backend de arquivo, formato simples (um arquivo por subject). V2 ) -var currentVersion = V1 +var ( + currentVersion = V1 + // current aponta para o Transport efetivamente em uso. É populado + // por SetVersion (e, na primeira chamada, por init). + current Transport +) + +func init() { + current = transportFor(currentVersion) +} + +// transportFor mapeia uma LogVersion para o Transport correspondente. +// Centraliza o dispatch para que adicionar uma nova versão (ex.: V3 = rede) +// signifique apenas estender este switch. +func transportFor(v LogVersion) Transport { + switch v { + case V2: + return &mchlogcorev2.MchLog + default: + return &mchlogcorev1.MchLog + } +} -// SetVersion chooses which version to use (V1 or V2). -// This should ideally be called before InitializeMchLog. +// SetVersion escolhe qual versão (V1 ou V2) será usada. +// Deve ser chamado antes de InitializeMchLog. func SetVersion(v LogVersion) { currentVersion = v + current = transportFor(v) } -// LogType is the facade structure that delegates calls to either V1 or V2 implementation +// LogType é o facade que delega ao Transport ativo. type LogType struct{} -// LogSubject records the content to the log file using the selected version +// LogSubject delega para o transporte ativo. func (l *LogType) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { - if currentVersion == V1 { - mchlogcorev1.MchLog.LogSubject(subject, content, errLog, ascendStackFrame...) - } else { - mchlogcorev2.MchLog.LogSubject(subject, content, errLog, ascendStackFrame...) - } + current.LogSubject(subject, content, errLog, ascendStackFrame...) } -// GetFileNameFromStreamName returns the log file path for the given subject +// GetFileNameFromStreamName delega para o transporte ativo. func (l *LogType) GetFileNameFromStreamName(subject string) string { - if currentVersion == V1 { - return mchlogcorev1.MchLog.GetFileNameFromStreamName(subject) - } - - return mchlogcorev2.MchLog.GetFileNameFromStreamName(subject) + return current.GetFileNameFromStreamName(subject) } -// GetIP returns the IP where the log is running (only available in V1, returns empty for V2) +// GetIP retorna o IP onde o log está rodando, quando o transporte ativo +// expõe essa informação (V1). Para outros transportes retorna "". func (l *LogType) GetIP() string { - if currentVersion == V1 { - return mchlogcorev1.MchLog.GetIP() + if v1, ok := current.(*mchlogcorev1.LogType); ok { + return v1.GetIP() } return "" } -// MchLog is the global instance of the log facade +// Close libera recursos do transporte ativo, quando ele implementa +// a interface Closer (somente backends que precisam de cleanup explícito, +// ex.: V3 sobre UDP). Para backends de arquivo (V1, V2) é no-op. +// Idempotência é responsabilidade do backend. +func (l *LogType) Close() error { + if c, ok := current.(Closer); ok { + return c.Close() + } + return nil +} + +// MchLog é a instância global do facade. var MchLog LogType -// InitializeMchLog initializes the selected version's backend with the given path +// InitializeMchLog inicializa o backend selecionado com o caminho dado. +// Para backends de arquivo (V1, V2) o path é o diretório base. func InitializeMchLog(path string) { versionName := "V1" - if currentVersion == V1 { - mchlogcorev1.InitializeMchLog(path) - } else { + switch currentVersion { + case V2: versionName = "V2" mchlogcorev2.InitializeMchLog(path) + default: + mchlogcorev1.InitializeMchLog(path) } - // The first log in info should be the version of the logger (v1 or v2) + // Primeiro log informa qual versão foi inicializada. MchLog.LogSubject("info", map[string]string{"message": "MchLogToolkit initialized", "version": versionName}, nil) } diff --git a/mchlogcore/mchlog_test.go b/mchlogcore/mchlog_test.go new file mode 100644 index 0000000..443278d --- /dev/null +++ b/mchlogcore/mchlog_test.go @@ -0,0 +1,57 @@ +package mchlogcore + +import ( + "testing" + + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev1" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" +) + +// TestSetVersionSwapsTransport garante que SetVersion troca o Transport +// ativo para o backend correspondente (V1 ou V2). É a invariante central +// do facade refatorado. +func TestSetVersionSwapsTransport(t *testing.T) { + t.Cleanup(func() { SetVersion(V1) }) + + SetVersion(V1) + if _, ok := current.(*mchlogcorev1.LogType); !ok { + t.Fatalf("V1 should select *mchlogcorev1.LogType, got %T", current) + } + + SetVersion(V2) + if _, ok := current.(*mchlogcorev2.LogType); !ok { + t.Fatalf("V2 should select *mchlogcorev2.LogType, got %T", current) + } +} + +// TestGetIPOnlyV1 confirma que GetIP delega para o backend apenas quando +// V1 está ativo; para outros backends retorna string vazia. +func TestGetIPOnlyV1(t *testing.T) { + t.Cleanup(func() { SetVersion(V1) }) + + SetVersion(V2) + if got := MchLog.GetIP(); got != "" { + t.Fatalf("expected empty IP for V2, got %q", got) + } + + SetVersion(V1) + // V1.GetIP pode retornar "" se a máquina não tem IP não-loopback, + // portanto não exigimos não-vazio — apenas que o tipo correto é consultado. + _ = MchLog.GetIP() +} + +// TestCloseNoopForFileBackends garante que Close no facade é no-op para +// V1 e V2 (que não implementam Closer) e não retorna erro. +func TestCloseNoopForFileBackends(t *testing.T) { + t.Cleanup(func() { SetVersion(V1) }) + + SetVersion(V1) + if err := MchLog.Close(); err != nil { + t.Fatalf("Close on V1 should be no-op, got %v", err) + } + + SetVersion(V2) + if err := MchLog.Close(); err != nil { + t.Fatalf("Close on V2 should be no-op, got %v", err) + } +} From 8e4782dce015452932e79befeaf391d0e39957c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:45:01 -0300 Subject: [PATCH 03/18] feat(mchlogcorev3): add NetworkConfig, Protocol and Configure Introduces the V3 network backend skeleton without any wire I/O yet. Public surface: * Protocol type with ProtocolGraylogUDP as the only value (others will plug in by extending the dispatch in Configure). * NetworkConfig{Protocol, Addr, Source, DisableGZIP}. Addr and Source are required; the toolkit deliberately does not auto-detect Source so the consuming service controls how its logs identify themselves in Graylog (pod name, env-aware composition, etc.). * Configure(cfg) validates required fields, applies the Protocol default, rejects unknown protocols, and stores the result. * ActiveConfig / IsConfigured for the transport layer (and tests). * DefaultSource() helper returning os.Hostname() or "unknown" for callers that prefer the hostname behavior without composing Source manually. DisableGZIP uses the inverse-flag pattern so the zero value preserves the documented default of GZIP-on without ambiguity between "false explicit" and "not set". Tests cover defaults, mandatory-field rejection, unknown-protocol rejection, DisableGZIP=true honored, and ActiveConfig before Configure. --- mchlogcorev3/config.go | 108 +++++++++++++++++++++++++++++++++++ mchlogcorev3/config_test.go | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 mchlogcorev3/config.go create mode 100644 mchlogcorev3/config_test.go diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go new file mode 100644 index 0000000..b3d69e7 --- /dev/null +++ b/mchlogcorev3/config.go @@ -0,0 +1,108 @@ +// Package mchlogcorev3 implementa o backend de rede da toolkit. +// Atualmente entrega logs via GELF UDP para o Graylog. Outros protocolos +// (graylog-tcp, syslog, splunk-hec, etc.) podem ser adicionados expondo +// novos valores de Protocol e a implementação correspondente. +package mchlogcorev3 + +import ( + "errors" + "os" + "sync" +) + +// Protocol identifica o protocolo de rede usado para enviar logs. +// Adicionar um novo protocolo significa criar uma nova constante e +// estender o dispatch interno; o resto da API pública não muda. +type Protocol string + +const ( + // ProtocolGraylogUDP envia logs em formato GELF via UDP. + ProtocolGraylogUDP Protocol = "graylog-udp" +) + +// NetworkConfig agrupa parâmetros de transporte de rede. +// Addr e Source são obrigatórios. Source é fornecido pelo serviço +// consumidor (tipicamente o nome do pod ou uma composição como +// "--"); a toolkit não tenta autodetectar. +type NetworkConfig struct { + // Protocol é o protocolo de rede. Default: ProtocolGraylogUDP. + Protocol Protocol + // Addr é o endereço do destino no formato "host:porta". Obrigatório. + Addr string + // Source é o valor que será gravado no campo GELF "host" + // (a coluna "source" no Graylog). Obrigatório, fornecido pelo caller. + Source string + // DisableGZIP desabilita a compressão GZIP do GELF UDP. + // Por padrão (zero value), GZIP fica habilitado. + DisableGZIP bool +} + +var ( + cfgMu sync.RWMutex + activeCfg NetworkConfig + configured bool +) + +// Configure normaliza e armazena a configuração de rede que será usada +// pelo transporte. Aplica defaults a Protocol; valida campos obrigatórios. +// Retorna erro se Addr ou Source estiverem vazios, ou se Protocol for +// desconhecido. +func Configure(cfg NetworkConfig) error { + if cfg.Addr == "" { + return errors.New("mchlogcorev3: Addr is required") + } + if cfg.Source == "" { + return errors.New("mchlogcorev3: Source is required (caller-provided)") + } + + if cfg.Protocol == "" { + cfg.Protocol = ProtocolGraylogUDP + } + switch cfg.Protocol { + case ProtocolGraylogUDP: + // suportado + default: + return errors.New("mchlogcorev3: unknown Protocol: " + string(cfg.Protocol)) + } + + cfgMu.Lock() + activeCfg = cfg + configured = true + cfgMu.Unlock() + return nil +} + +// ActiveConfig retorna uma cópia da configuração ativa. Útil para +// testes e para o transporte ler os parâmetros já normalizados. +// Antes de Configure ser chamado, devolve um NetworkConfig zero-valued. +func ActiveConfig() NetworkConfig { + cfgMu.RLock() + defer cfgMu.RUnlock() + return activeCfg +} + +// IsConfigured indica se Configure já foi chamado com sucesso. +func IsConfigured() bool { + cfgMu.RLock() + defer cfgMu.RUnlock() + return configured +} + +// DefaultSource é um helper para callers que não querem compor o Source +// manualmente. Devolve o hostname do sistema (os.Hostname) ou "unknown" +// caso a chamada falhe ou retorne string vazia. +func DefaultSource() string { + if h, err := os.Hostname(); err == nil && h != "" { + return h + } + return "unknown" +} + +// resetConfig limpa o estado de configuração. Usado apenas em testes +// (não exportado). +func resetConfig() { + cfgMu.Lock() + activeCfg = NetworkConfig{} + configured = false + cfgMu.Unlock() +} diff --git a/mchlogcorev3/config_test.go b/mchlogcorev3/config_test.go new file mode 100644 index 0000000..52ee19a --- /dev/null +++ b/mchlogcorev3/config_test.go @@ -0,0 +1,109 @@ +package mchlogcorev3 + +import ( + "testing" +) + +// TestDefaultSource garante que o helper devolve uma string não vazia +// (tipicamente o hostname ou "unknown" caso o sistema não retorne hostname). +func TestDefaultSource(t *testing.T) { + got := DefaultSource() + if got == "" { + t.Fatalf("DefaultSource should never return empty string") + } +} + +// TestConfigureAppliesDefaults garante que Protocol recebe valor default +// quando não especificado e que GZIP fica habilitado por padrão. Source e +// Addr não são auto-preenchidos. +func TestConfigureAppliesDefaults(t *testing.T) { + t.Cleanup(resetConfig) + + cfg := NetworkConfig{ + Addr: "graylog.dev:12201", + Source: "svc-x", + } + if err := Configure(cfg); err != nil { + t.Fatalf("Configure failed: %v", err) + } + + got := ActiveConfig() + if got.Protocol != ProtocolGraylogUDP { + t.Errorf("Protocol default mismatch: got %q want %q", got.Protocol, ProtocolGraylogUDP) + } + if got.DisableGZIP { + t.Errorf("GZIP must be enabled by default (DisableGZIP=false)") + } + if got.Source != "svc-x" { + t.Errorf("Source must not be auto-filled: got %q", got.Source) + } + if got.Addr != "graylog.dev:12201" { + t.Errorf("Addr mismatch: got %q", got.Addr) + } +} + +// TestConfigureDisableGZIPRespected garante que callers podem desabilitar +// gzip explicitamente. +func TestConfigureDisableGZIPRespected(t *testing.T) { + t.Cleanup(resetConfig) + + if err := Configure(NetworkConfig{ + Addr: "graylog.dev:12201", + Source: "svc-x", + DisableGZIP: true, + }); err != nil { + t.Fatalf("Configure failed: %v", err) + } + if !ActiveConfig().DisableGZIP { + t.Errorf("DisableGZIP=true was overwritten") + } +} + +// TestConfigureRejectsEmptyAddr garante validação obrigatória de Addr. +func TestConfigureRejectsEmptyAddr(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(NetworkConfig{Source: "svc-x"}) + if err == nil { + t.Fatalf("Configure should reject empty Addr") + } +} + +// TestConfigureRejectsEmptySource garante validação obrigatória de Source. +// Source é caller-provided por design (toolkit não autodetecta). +func TestConfigureRejectsEmptySource(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(NetworkConfig{Addr: "graylog.dev:12201"}) + if err == nil { + t.Fatalf("Configure should reject empty Source") + } +} + +// TestConfigureRejectsUnknownProtocol garante que protocolos não suportados +// retornem erro (futuro-proofing para quando outros protocolos forem +// adicionados). +func TestConfigureRejectsUnknownProtocol(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(NetworkConfig{ + Protocol: "graylog-tcp", + Addr: "graylog.dev:12201", + Source: "svc-x", + }) + if err == nil { + t.Fatalf("Configure should reject unknown protocol") + } +} + +// TestActiveConfigBeforeConfigure garante que consultar ActiveConfig +// antes de Configure não panica e devolve config zero-valued. +func TestActiveConfigBeforeConfigure(t *testing.T) { + t.Cleanup(resetConfig) + resetConfig() + + got := ActiveConfig() + if got.Addr != "" || got.Source != "" { + t.Errorf("ActiveConfig before Configure should be zero-valued, got %+v", got) + } +} From 06b331040b1d4f0142049d3a2c3accfb2e62b29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:50:31 -0300 Subject: [PATCH 04/18] feat(mchlogcorev3): add GELF builder and level mapping Adds the github.com/Graylog2/go-gelf dependency (the master tree; this fork has no /v2/ module path despite the GitHub branch). Introduces: * levelToSyslog: maps the toolkit's log levels to GELF/syslog severities (fatal=2, error=3, warn=4, info=6, debug/test=7). Unknown levels default to INFO so a typo never silences output. * buildGELFMessage: builds a *gelf.Message from the payload that transports receive (the []byte JSON produced by formatLog, plus map[string]any / map[string]string variants for the init log). Field naming follows the agreed Graylog mapping: - host = cfg.Source (controlled by the consuming service) - short_message = payload "message" - level = syslog severity - _application_name = service name from NewLogger - _log_id = "-mchlog-" (mirrors V1/V2 directory layout so old filter habits keep working) - _level_name = textual level - _file/_line/_trace = renamed from payload "source"/"line"/"trace" to avoid colliding with Graylog's "source" column (which comes from host) - any other payload key becomes "_" in Extra - _error = errLog.Error() when errLog != nil Tests cover level mapping (all six levels + unknown), required GELF 1.1 fields, custom field composition, error promotion, map content type, missing message, invalid JSON, and JSON serialization sanity. --- go.mod | 5 +- go.sum | 2 + mchlogcorev3/gelf.go | 175 ++++++++++++++++++++++++++++++++++++++ mchlogcorev3/gelf_test.go | 175 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 mchlogcorev3/gelf.go create mode 100644 mchlogcorev3/gelf_test.go diff --git a/go.mod b/go.mod index e71d3c0..edd4ac4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/gaudiumsoftware/mchlogtoolkitgo go 1.22.1 -require github.com/rs/zerolog v1.33.0 +require ( + github.com/Graylog2/go-gelf v0.0.0-20170811154226-7ebf4f536d8f + github.com/rs/zerolog v1.33.0 +) require ( github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 98afda4..72b088d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Graylog2/go-gelf v0.0.0-20170811154226-7ebf4f536d8f h1:xMWj7GzE4gCkm8e+661/GJHDXr4h7/jt4kM1Vvr9c5k= +github.com/Graylog2/go-gelf v0.0.0-20170811154226-7ebf4f536d8f/go.mod h1:fBaQWrftOD5CrVCUfoYGHs4X4VViTuGOXA8WloCjTY0= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= diff --git a/mchlogcorev3/gelf.go b/mchlogcorev3/gelf.go new file mode 100644 index 0000000..75733c6 --- /dev/null +++ b/mchlogcorev3/gelf.go @@ -0,0 +1,175 @@ +package mchlogcorev3 + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "time" + + "github.com/Graylog2/go-gelf/gelf" +) + +// handledPayloadKeys são as chaves do payload que o builder já trata +// explicitamente (Short, _file, _line, _trace) e portanto devem ser +// puladas no fan-out genérico de Extra. +// +// Observação: campos GELF top-level (version, host, short_message, +// timestamp, level, facility) NÃO são listados aqui porque o GELF spec +// distingue top-level "x" de custom field "_x" — então uma chave +// "version" no payload pode virar "_version" em Extra sem conflito. +var handledPayloadKeys = map[string]struct{}{ + "message": {}, + "source": {}, + "line": {}, + "trace": {}, +} + +// levelToSyslog converte um level da toolkit ("debug", "info", ...) para +// o severity numérico do GELF (que segue syslog: 0..7). +// Levels desconhecidos são tratados como info (6). +func levelToSyslog(level string) int32 { + switch level { + case "fatal": + return gelf.LOG_CRIT // 2 + case "error": + return gelf.LOG_ERR // 3 + case "warn": + return gelf.LOG_WARNING // 4 + case "info": + return gelf.LOG_INFO // 6 + case "debug", "test": + return gelf.LOG_DEBUG // 7 + default: + return gelf.LOG_INFO + } +} + +// buildGELFMessage monta um *gelf.Message a partir do payload recebido +// pelo Transport. Aceita content como []byte JSON, string JSON, ou +// map[string]any / map[string]string. +// +// Convenções de campos: +// - Short = chave "message" do payload (ou string vazia se ausente) +// - Host = cfg.Source +// - Level = mapping syslog do parâmetro level +// - Extra: +// - _application_name = serviceName +// - _log_id = "-mchlog-" +// - _level_name = level +// - _file = chave "source" do payload (renomeada para evitar +// colidir com a coluna "source" do Graylog) +// - _line = chave "line" +// - _trace = chave "trace" +// - demais chaves = prefixadas com "_" (a menos que reservadas) +// - _error = errLog.Error() quando errLog != nil +func buildGELFMessage(serviceName, level string, content any, errLog error, cfg NetworkConfig) (*gelf.Message, error) { + fields, err := contentToMap(content) + if err != nil { + return nil, err + } + + msg := &gelf.Message{ + Version: "1.1", + Host: cfg.Source, + TimeUnix: float64(time.Now().UTC().UnixNano()) / float64(time.Second), + Level: levelToSyslog(level), + Extra: make(map[string]any), + } + + // Short = "message" do payload, se presente. + if v, ok := fields["message"]; ok { + if s, ok := v.(string); ok { + msg.Short = s + } else { + msg.Short = fmt.Sprintf("%v", v) + } + } + + // Custom fields fixos (sempre presentes). + msg.Extra["_application_name"] = serviceName + msg.Extra["_log_id"] = serviceName + "-mchlog-" + level + msg.Extra["_level_name"] = level + + // Renomeio de source → _file para evitar colisão com a coluna + // "source" do Graylog (que vem de Host). + if v, ok := fields["source"]; ok { + msg.Extra["_file"] = stringify(v) + } + if v, ok := fields["line"]; ok { + msg.Extra["_line"] = stringify(v) + } + if v, ok := fields["trace"]; ok { + msg.Extra["_trace"] = stringify(v) + } + + // Demais chaves do payload viram custom fields prefixados com "_". + // Pula as que já foram tratadas explicitamente (message → Short; + // source/line/trace → _file/_line/_trace). + for k, v := range fields { + if _, handled := handledPayloadKeys[k]; handled { + continue + } + msg.Extra["_"+k] = v + } + + // Erro associado. + if errLog != nil { + msg.Extra["_error"] = errLog.Error() + } + + return msg, nil +} + +// contentToMap converte os formatos de content suportados (mapas, slice +// de bytes contendo JSON, string contendo JSON) para map[string]any. +func contentToMap(content any) (map[string]any, error) { + if content == nil { + return nil, errors.New("mchlogcorev3: nil content") + } + + switch v := content.(type) { + case map[string]any: + return v, nil + case map[string]string: + out := make(map[string]any, len(v)) + for k, s := range v { + out[k] = s + } + return out, nil + case []byte: + var out map[string]any + if err := json.Unmarshal(v, &out); err != nil { + return nil, err + } + return out, nil + case string: + var out map[string]any + if err := json.Unmarshal([]byte(v), &out); err != nil { + return nil, err + } + return out, nil + } + + // Fallback via reflect para mapas com value type estático que não + // caíram nos casos acima (ex.: map[string]int). + rv := reflect.ValueOf(content) + if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { + out := make(map[string]any, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + out[iter.Key().String()] = iter.Value().Interface() + } + return out, nil + } + + return nil, fmt.Errorf("mchlogcorev3: unsupported content type %T", content) +} + +// stringify converte qualquer valor a string, com fast-path para string. +func stringify(v any) string { + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} diff --git a/mchlogcorev3/gelf_test.go b/mchlogcorev3/gelf_test.go new file mode 100644 index 0000000..bc069f6 --- /dev/null +++ b/mchlogcorev3/gelf_test.go @@ -0,0 +1,175 @@ +package mchlogcorev3 + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Graylog2/go-gelf/gelf" +) + +// TestLevelToSyslogMapping garante que cada level da toolkit mapeia +// para o severity syslog/GELF correto. +func TestLevelToSyslogMapping(t *testing.T) { + cases := map[string]int32{ + "fatal": gelf.LOG_CRIT, // 2 + "error": gelf.LOG_ERR, // 3 + "warn": gelf.LOG_WARNING, // 4 + "info": gelf.LOG_INFO, // 6 + "debug": gelf.LOG_DEBUG, // 7 + "test": gelf.LOG_DEBUG, // 7 (test trata como debug) + } + for lvl, want := range cases { + t.Run(lvl, func(t *testing.T) { + if got := levelToSyslog(lvl); got != want { + t.Errorf("levelToSyslog(%q) = %d, want %d", lvl, got, want) + } + }) + } +} + +// TestLevelToSyslogUnknownDefaultsToInfo trata level fora do conjunto. +func TestLevelToSyslogUnknownDefaultsToInfo(t *testing.T) { + if got := levelToSyslog("nonsense"); got != gelf.LOG_INFO { + t.Errorf("unknown level should default to INFO(6), got %d", got) + } +} + +// TestBuildGELFMessageRequiredFields garante presença e valor dos +// campos obrigatórios do GELF 1.1 a partir de um payload []byte JSON +// que reproduz a saída do formatLog do logger.go. +func TestBuildGELFMessageRequiredFields(t *testing.T) { + payload := []byte(`{"message":"hello","level":"info","source":"foo.go","line":"42","trace":""}`) + msg, err := buildGELFMessage("payments-api", "info", payload, nil, NetworkConfig{ + Source: "pod-1", + }) + if err != nil { + t.Fatalf("buildGELFMessage failed: %v", err) + } + if msg.Version != "1.1" { + t.Errorf("Version = %q, want 1.1", msg.Version) + } + if msg.Host != "pod-1" { + t.Errorf("Host = %q, want pod-1", msg.Host) + } + if msg.Short != "hello" { + t.Errorf("Short = %q, want hello", msg.Short) + } + if msg.TimeUnix <= 0 { + t.Errorf("TimeUnix should be set, got %f", msg.TimeUnix) + } + if msg.Level != gelf.LOG_INFO { + t.Errorf("Level = %d, want %d", msg.Level, gelf.LOG_INFO) + } +} + +// TestBuildGELFMessageCustomFields garante composição correta de +// _application_name, _log_id, _level_name, _file e _line. +func TestBuildGELFMessageCustomFields(t *testing.T) { + payload := []byte(`{"message":"hi","level":"debug","source":"internal/foo.go","line":"99","trace":"abc"}`) + msg, err := buildGELFMessage("payments-api", "debug", payload, nil, NetworkConfig{ + Source: "pod-1", + }) + if err != nil { + t.Fatalf("build failed: %v", err) + } + + checks := map[string]string{ + "_application_name": "payments-api", + "_log_id": "payments-api-mchlog-debug", + "_level_name": "debug", + "_file": "internal/foo.go", + "_line": "99", + "_trace": "abc", + } + for k, want := range checks { + got, ok := msg.Extra[k] + if !ok { + t.Errorf("missing custom field %q", k) + continue + } + if gs, _ := got.(string); gs != want { + t.Errorf("Extra[%q] = %v, want %q", k, got, want) + } + } +} + +// TestBuildGELFMessageWithError garante que um errLog não-nil produz +// _error e mantém os demais campos. +func TestBuildGELFMessageWithError(t *testing.T) { + payload := []byte(`{"message":"boom","level":"error","source":"x.go","line":"7","trace":""}`) + msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), NetworkConfig{ + Source: "pod-1", + }) + if err != nil { + t.Fatalf("build failed: %v", err) + } + if msg.Level != gelf.LOG_ERR { + t.Errorf("Level = %d, want %d", msg.Level, gelf.LOG_ERR) + } + got, ok := msg.Extra["_error"] + if !ok { + t.Fatalf("missing _error") + } + if s, _ := got.(string); !strings.Contains(s, "kaboom") { + t.Errorf("_error = %v, want contains kaboom", got) + } +} + +// TestBuildGELFMessageAcceptsMap garante que content como map também +// funciona (caso da mensagem de init disparada pelo facade). +func TestBuildGELFMessageAcceptsMap(t *testing.T) { + content := map[string]string{ + "message": "MchLogToolkit initialized", + "version": "V3", + } + msg, err := buildGELFMessage("svc", "info", content, nil, NetworkConfig{Source: "pod-1"}) + if err != nil { + t.Fatalf("build failed: %v", err) + } + if msg.Short != "MchLogToolkit initialized" { + t.Errorf("Short = %q", msg.Short) + } + if v, _ := msg.Extra["_version"].(string); v != "V3" { + t.Errorf("Extra[_version] = %v, want V3", msg.Extra["_version"]) + } +} + +// TestBuildGELFMessageMissingMessage garante que payload sem campo +// "message" usa string vazia em Short e não falha. +func TestBuildGELFMessageMissingMessage(t *testing.T) { + payload := []byte(`{"level":"info"}`) + msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}) + if err != nil { + t.Fatalf("build failed: %v", err) + } + if msg.Short != "" { + t.Errorf("Short = %q, want empty", msg.Short) + } +} + +// TestBuildGELFMessageInvalidJSONReturnsError garante que payload +// malformado produz erro em vez de panic. +func TestBuildGELFMessageInvalidJSONReturnsError(t *testing.T) { + payload := []byte(`{not json`) + if _, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}); err == nil { + t.Fatalf("expected error on invalid JSON") + } +} + +// TestBuildGELFMessageSerializable confirma que a mensagem produzida +// serializa em JSON válido com Extra inline (formato exigido pelo GELF). +func TestBuildGELFMessageSerializable(t *testing.T) { + payload := []byte(`{"message":"x","level":"info","source":"a.go","line":"1","trace":""}`) + msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}) + if err != nil { + t.Fatalf("build failed: %v", err) + } + // MarshalJSONBuf é o método usado pelo gelf.Writer ao enviar. + // Consumimos via json.Marshal padrão para o teste — aqui só + // verificamos que os campos identificáveis são serializáveis. + if _, err := json.Marshal(msg); err != nil { + t.Fatalf("Message not serializable: %v", err) + } +} From 1b0be02f1316a86e8bc856fecadb29c5afc6fce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:52:15 -0300 Subject: [PATCH 05/18] feat(mchlogcorev3): add Graylog UDP transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the V3 backend on top of github.com/Graylog2/go-gelf: * graylogUDP holds the gelf.Writer, the active NetworkConfig and the service name. Satisfies mchlogcore.Transport (LogSubject and GetFileNameFromStreamName) and mchlogcore.Closer (Close). * Initialize(path) extracts the service from the path (matching the "//" convention V1/V2 already use), dials the GELF UDP writer, applies CompressionType from cfg.DisableGZIP, and publishes the global MchLog. Configure must run first. * LogSubject builds the GELF Message via buildGELFMessage and sends via WriteMessage. Send errors do not propagate to the caller; they are reported by warnOnce. * GetFileNameFromStreamName returns "udp:///" purely as a logical descriptor for tests and observability — no real file. * Close is idempotent; closes the writer once and short-circuits on subsequent calls. * warnOnce rate-limits stderr warnings to one line per 60s so a Graylog outage cannot flood logs. Tests cover the happy path against a local UDP listener (asserting GELF 1.1 fields including _application_name and _log_id), the logical descriptor format, idempotent Close, and the Configure-first guard. --- mchlogcorev3/mchlogv3.go | 141 ++++++++++++++++++++++++++++++ mchlogcorev3/mchlogv3_test.go | 156 ++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 mchlogcorev3/mchlogv3.go create mode 100644 mchlogcorev3/mchlogv3_test.go diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go new file mode 100644 index 0000000..c37f4ba --- /dev/null +++ b/mchlogcorev3/mchlogv3.go @@ -0,0 +1,141 @@ +package mchlogcorev3 + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Graylog2/go-gelf/gelf" +) + +// warnWindow é a janela de rate-limit das mensagens de aviso impressas +// no stderr quando o envio para o Graylog falha. Uma falha gera no +// máximo um aviso por janela. +const warnWindow = 60 * time.Second + +// graylogUDP implementa a interface mchlogcore.Transport (e Closer) +// enviando logs em formato GELF via UDP para um servidor Graylog. +type graylogUDP struct { + writer *gelf.Writer + cfg NetworkConfig + serviceName string + + mu sync.Mutex + closed bool + lastWarn time.Time +} + +// MchLog é a instância global do transporte V3. É populada por +// Initialize. Antes disso, é nil. +var MchLog *graylogUDP + +// Initialize prepara o transporte V3 para uso. O parâmetro path tem +// a mesma forma usada por V1/V2 ("//") e o nome do +// serviço é extraído do último segmento. Configure precisa ter sido +// chamado antes; caso contrário, retorna erro. +func Initialize(path string) error { + if !IsConfigured() { + return errors.New("mchlogcorev3: Configure must be called before Initialize") + } + + service := serviceFromPath(path) + if service == "" { + return errors.New("mchlogcorev3: cannot extract service name from path: " + path) + } + + cfg := ActiveConfig() + + w, err := gelf.NewWriter(cfg.Addr) + if err != nil { + return fmt.Errorf("mchlogcorev3: dial GELF UDP %s: %w", cfg.Addr, err) + } + if cfg.DisableGZIP { + w.CompressionType = gelf.CompressNone + } else { + w.CompressionType = gelf.CompressGzip + } + + MchLog = &graylogUDP{ + writer: w, + cfg: cfg, + serviceName: service, + } + return nil +} + +// LogSubject monta a mensagem GELF e envia via UDP. Falhas no envio +// são registradas via warnOnce e silenciadas para o caller. +func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { + if g == nil || subject == "" { + return + } + + msg, err := buildGELFMessage(g.serviceName, subject, content, errLog, g.cfg) + if err != nil { + g.warnOnce(err) + return + } + + if err := g.writer.WriteMessage(msg); err != nil { + g.warnOnce(err) + } + + // ascendStackFrame é mantido na assinatura por contrato com o + // facade; o caller pode usar para skip no zerolog. V3 já popula + // _file/_line a partir do payload, então é silenciosamente ignorado. + _ = ascendStackFrame +} + +// GetFileNameFromStreamName devolve um descritor lógico do "fluxo" de +// log, no formato "udp:///". Usado apenas para +// observabilidade e compatibilidade com testes existentes. +func (g *graylogUDP) GetFileNameFromStreamName(subject string) string { + if g == nil { + return "" + } + return "udp://" + g.cfg.Addr + "/" + subject +} + +// Close fecha o writer GELF. Idempotente. +func (g *graylogUDP) Close() error { + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + if g.closed { + return nil + } + g.closed = true + if g.writer != nil { + _ = g.writer.Close() + } + return nil +} + +// warnOnce imprime um aviso no stderr respeitando warnWindow: +// no máximo uma linha por janela quando os envios falham em sequência. +func (g *graylogUDP) warnOnce(err error) { + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now() + if now.Sub(g.lastWarn) < warnWindow { + return + } + g.lastWarn = now + fmt.Fprintf(os.Stderr, "mchlogcorev3: GELF UDP send failed: %v\n", err) +} + +// serviceFromPath extrai o nome do serviço de um path no formato +// "//" (ou variações com separadores Windows). +func serviceFromPath(path string) string { + p := strings.TrimRight(filepath.ToSlash(path), "/ ") + if p == "" { + return "" + } + return filepath.Base(p) +} diff --git a/mchlogcorev3/mchlogv3_test.go b/mchlogcorev3/mchlogv3_test.go new file mode 100644 index 0000000..284ff83 --- /dev/null +++ b/mchlogcorev3/mchlogv3_test.go @@ -0,0 +1,156 @@ +package mchlogcorev3 + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net" + "testing" + "time" +) + +// listenUDP cria um listener UDP em porta efêmera e devolve endereço +// (host:porta) e a conexão para leitura. +func listenUDP(t *testing.T) (string, net.PacketConn) { + t.Helper() + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen UDP: %v", err) + } + return conn.LocalAddr().String(), conn +} + +// readDatagram lê um datagrama do listener, com timeout. Retorna os +// bytes brutos. Se o conteúdo estiver gzipado, descomprime. +func readDatagram(t *testing.T, conn net.PacketConn) []byte { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 64*1024) + n, _, err := conn.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + data := buf[:n] + // gzip magic + if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer gr.Close() + out, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("gzip read: %v", err) + } + return out + } + return data +} + +// TestGraylogUDPSendsValidGELF mostra que o transporte UDP entrega um +// datagrama em formato GELF 1.1 ao destino, com short_message, host, +// level e o custom field _application_name corretos. +func TestGraylogUDPSendsValidGELF(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{ + Addr: addr, + Source: "pod-1", + }); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/payments-api/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + payload := []byte(`{"message":"hello","level":"info","source":"x.go","line":"1","trace":""}`) + MchLog.LogSubject("info", payload, nil) + + raw := readDatagram(t, conn) + + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("invalid GELF JSON: %v\nraw=%s", err, string(raw)) + } + if got["version"] != "1.1" { + t.Errorf("version=%v want 1.1", got["version"]) + } + if got["host"] != "pod-1" { + t.Errorf("host=%v want pod-1", got["host"]) + } + if got["short_message"] != "hello" { + t.Errorf("short_message=%v want hello", got["short_message"]) + } + if got["_application_name"] != "payments-api" { + t.Errorf("_application_name=%v want payments-api", got["_application_name"]) + } + if got["_log_id"] != "payments-api-mchlog-info" { + t.Errorf("_log_id=%v want payments-api-mchlog-info", got["_log_id"]) + } + // level (syslog) chega como número JSON + if lvl, ok := got["level"].(float64); !ok || int(lvl) != 6 { + t.Errorf("level=%v want 6", got["level"]) + } +} + +// TestGraylogUDPGetFileNameFromStreamName devolve descritor lógico +// quando V3 está inicializado (não há arquivo real). +func TestGraylogUDPGetFileNameFromStreamName(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/payments-api/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + got := MchLog.GetFileNameFromStreamName("info") + want := "udp://" + addr + "/info" + if got != want { + t.Errorf("descriptor = %q want %q", got, want) + } +} + +// TestGraylogUDPCloseIdempotent garante que Close pode ser chamado +// múltiplas vezes sem erro. +func TestGraylogUDPCloseIdempotent(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + + if err := MchLog.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := MchLog.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +// TestGraylogUDPInitializeRequiresConfigure garante que Initialize +// sem Configure prévio retorna erro claro. +func TestGraylogUDPInitializeRequiresConfigure(t *testing.T) { + t.Cleanup(resetConfig) + resetConfig() + + if err := Initialize("/applog/svc/"); err == nil { + t.Fatalf("Initialize without Configure should error") + } +} From 1072cd6b731e8f0343dca740e9e2b3bb37bb20f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 19:54:18 -0300 Subject: [PATCH 06/18] feat(mchlogcore): wire V3 dispatch into facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the V3 LogVersion constant alongside V1 and V2 and extends the internal switch in transportFor and InitializeMchLog. V3 routes calls to mchlogcorev3.MchLog (which was prepared by mchlogcorev3.Configure and mchlogcorev3.Initialize). Initialization order is the subtle point: SetVersion(V3) marks intent but mchlogcorev3.MchLog is still nil until Initialize succeeds, so InitializeMchLog now re-binds the package-level current Transport after the backend init returns. The transport methods on *graylogUDP already tolerate a nil receiver, so a misordered SetVersion alone cannot panic the caller. If V3 Initialize fails (Configure not called, dial failure, bad path), the error is reported once on stderr and the boot info message is skipped — V1 and V2 paths are unaffected. A new test in mchlogcore drives the full chain: Configure → SetVersion(V3) → InitializeMchLog → MchLog.LogSubject, asserting the GELF datagram on a local UDP listener and verifying that the boot info message identifies V3. --- mchlogcore/mchlog.go | 41 +++++++++++-- mchlogcore/v3_dispatch_test.go | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 mchlogcore/v3_dispatch_test.go diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index 7c74361..1fb142c 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -1,8 +1,12 @@ package mchlogcore import ( + "fmt" + "os" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev1" "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" ) // Asserções de tempo de compilação garantindo que cada backend @@ -20,6 +24,10 @@ const ( V1 LogVersion = iota // V2 — backend de arquivo, formato simples (um arquivo por subject). V2 + // V3 — backend de rede (família). Protocolos suportados são + // configurados via mchlogcorev3.NetworkConfig.Protocol; primeiro + // protocolo entregue é GELF UDP para Graylog. + V3 ) var ( @@ -34,10 +42,18 @@ func init() { } // transportFor mapeia uma LogVersion para o Transport correspondente. -// Centraliza o dispatch para que adicionar uma nova versão (ex.: V3 = rede) -// signifique apenas estender este switch. +// Centraliza o dispatch: adicionar uma nova versão significa estender +// apenas este switch. +// +// Para V3, devolvemos a global mchlogcorev3.MchLog. Antes de +// mchlogcorev3.Initialize esse ponteiro é nil; chamadas via interface +// permanecem seguras porque os métodos de *graylogUDP toleram receiver +// nil. Após Initialize, transportFor é re-invocado por InitializeMchLog +// para refletir o novo ponteiro. func transportFor(v LogVersion) Transport { switch v { + case V3: + return mchlogcorev3.MchLog case V2: return &mchlogcorev2.MchLog default: @@ -89,17 +105,34 @@ func (l *LogType) Close() error { var MchLog LogType // InitializeMchLog inicializa o backend selecionado com o caminho dado. -// Para backends de arquivo (V1, V2) o path é o diretório base. +// Para backends de arquivo (V1, V2) o path é o diretório base. Para V3 +// (rede), o path é usado apenas para extrair o nome do serviço (último +// segmento, no formato "//"). func InitializeMchLog(path string) { - versionName := "V1" + var versionName string + var initErr error + switch currentVersion { + case V3: + versionName = "V3" + initErr = mchlogcorev3.Initialize(path) case V2: versionName = "V2" mchlogcorev2.InitializeMchLog(path) default: + versionName = "V1" mchlogcorev1.InitializeMchLog(path) } + // Re-vincula current ao transporte agora inicializado. Necessário + // para V3, cuja global passou de nil para o ponteiro válido. + current = transportFor(currentVersion) + + if initErr != nil { + fmt.Fprintf(os.Stderr, "mchlogcore: %s initialization failed: %v\n", versionName, initErr) + return + } + // Primeiro log informa qual versão foi inicializada. MchLog.LogSubject("info", map[string]string{"message": "MchLogToolkit initialized", "version": versionName}, nil) } diff --git a/mchlogcore/v3_dispatch_test.go b/mchlogcore/v3_dispatch_test.go new file mode 100644 index 0000000..68ec44c --- /dev/null +++ b/mchlogcore/v3_dispatch_test.go @@ -0,0 +1,101 @@ +package mchlogcore + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net" + "testing" + "time" + + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" +) + +func listenUDP(t *testing.T) (string, net.PacketConn) { + t.Helper() + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen UDP: %v", err) + } + return conn.LocalAddr().String(), conn +} + +func readDatagram(t *testing.T, conn net.PacketConn) []byte { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 64*1024) + n, _, err := conn.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + data := buf[:n] + if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer gr.Close() + out, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("gzip read: %v", err) + } + return out + } + return data +} + +// TestSetVersionV3DispatchesToGraylog cobre o caminho completo: +// Configure → SetVersion(V3) → InitializeMchLog → MchLog.LogSubject +// gera datagrama GELF no listener mock com _application_name correto. +func TestSetVersionV3DispatchesToGraylog(t *testing.T) { + t.Cleanup(func() { SetVersion(V1) }) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := mchlogcorev3.Configure(mchlogcorev3.NetworkConfig{ + Addr: addr, + Source: "pod-1", + }); err != nil { + t.Fatalf("Configure: %v", err) + } + + SetVersion(V3) + InitializeMchLog("/applog/payments-api/") + t.Cleanup(func() { _ = MchLog.Close() }) + + // O primeiro datagrama é a mensagem de "MchLogToolkit initialized". + first := readDatagram(t, conn) + var initMsg map[string]any + if err := json.Unmarshal(first, &initMsg); err != nil { + t.Fatalf("init datagram invalid JSON: %v\nraw=%s", err, first) + } + if got := initMsg["short_message"]; got != "MchLogToolkit initialized" { + t.Errorf("init short_message = %v", got) + } + if got := initMsg["_version"]; got != "V3" { + t.Errorf("init _version = %v want V3", got) + } + + // Mensagem de aplicação. + MchLog.LogSubject("info", []byte(`{"message":"hello","level":"info","source":"x.go","line":"1","trace":""}`), nil) + raw := readDatagram(t, conn) + + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("invalid GELF JSON: %v", err) + } + if got["short_message"] != "hello" { + t.Errorf("short_message=%v", got["short_message"]) + } + if got["host"] != "pod-1" { + t.Errorf("host=%v", got["host"]) + } + if got["_application_name"] != "payments-api" { + t.Errorf("_application_name=%v", got["_application_name"]) + } + if got["_log_id"] != "payments-api-mchlog-info" { + t.Errorf("_log_id=%v", got["_log_id"]) + } +} From a917ea5431c4bd9f764d5bbabc6536705ab06be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 20:05:36 -0300 Subject: [PATCH 07/18] test(mchlogcorev3): cover level mapping, gzip toggle, edge cases Pushes V3 coverage from 74% to 87.8%, well above the 80% target. End-to-end (UDP listener + JSON decode) tests: - All six toolkit levels round-trip with the right syslog severity and the right _level_name and _log_id in the datagram. - DisableGZIP=true emits raw JSON datagrams (no gzip magic). - DisableGZIP zero-value emits gzipped datagrams (default). - LogSubject with empty subject does not produce any datagram. Internal helper tests: - Nil receiver tolerated by LogSubject, GetFileNameFromStreamName and Close (defensive guard against pre-Initialize dispatch). - contentToMap covers string-as-JSON, reflect fallback for map[string]int, unsupported type rejection, and nil rejection. - serviceFromPath handles trailing slash, ./ prefix, empty input, and bare separator. - stringify handles non-string and nil. warnOnce stays uncovered until T11 (failure-mode tests intentionally exercise the stderr rate limiter). --- mchlogcorev3/coverage_test.go | 244 ++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 mchlogcorev3/coverage_test.go diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go new file mode 100644 index 0000000..6d83951 --- /dev/null +++ b/mchlogcorev3/coverage_test.go @@ -0,0 +1,244 @@ +package mchlogcorev3 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Graylog2/go-gelf/gelf" +) + +func deadline2s() time.Time { return time.Now().Add(2 * time.Second) } +func deadline100ms() time.Time { return time.Now().Add(100 * time.Millisecond) } + +// TestDatagramLevelMappingAllLevels percorre cada level da toolkit e +// verifica que o datagrama enviado carrega o severity numérico correto +// e o _level_name textual correspondente. +func TestDatagramLevelMappingAllLevels(t *testing.T) { + cases := []struct { + level string + wantSyslog int32 + }{ + {"fatal", gelf.LOG_CRIT}, + {"error", gelf.LOG_ERR}, + {"warn", gelf.LOG_WARNING}, + {"info", gelf.LOG_INFO}, + {"debug", gelf.LOG_DEBUG}, + {"test", gelf.LOG_DEBUG}, + } + + for _, tc := range cases { + t.Run(tc.level, func(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + payload := []byte(`{"message":"x","level":"` + tc.level + `","source":"a.go","line":"1","trace":""}`) + MchLog.LogSubject(tc.level, payload, nil) + + raw := readDatagram(t, conn) + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("invalid GELF JSON: %v", err) + } + if lvl, _ := got["level"].(float64); int32(lvl) != tc.wantSyslog { + t.Errorf("level=%v want %d", got["level"], tc.wantSyslog) + } + if got["_level_name"] != tc.level { + t.Errorf("_level_name=%v want %q", got["_level_name"], tc.level) + } + if got["_log_id"] != "svc-mchlog-"+tc.level { + t.Errorf("_log_id=%v want svc-mchlog-%s", got["_log_id"], tc.level) + } + }) + } +} + +// TestDatagramGZIPDisabled garante que com DisableGZIP=true o datagrama +// chega como JSON bruto (sem magic bytes de gzip). +func TestDatagramGZIPDisabled(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + MchLog.LogSubject("info", []byte(`{"message":"plain"}`), nil) + + // readDatagram já descomprime se gzip; aqui consultamos os primeiros + // bytes via ReadFrom direto antes da descompressão para conferir. + // Como readDatagram não dá acesso aos bytes brutos, testamos via + // um listener próprio. + addr2, conn2 := listenUDP(t) + defer conn2.Close() + if err := Configure(NetworkConfig{Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { + t.Fatalf("Configure: %v", err) + } + _ = MchLog.Close() + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize2: %v", err) + } + MchLog.LogSubject("info", []byte(`{"message":"plain"}`), nil) + + buf := make([]byte, 4096) + _ = conn2.SetReadDeadline(deadline2s()) + n, _, err := conn2.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + data := buf[:n] + if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b { + t.Fatalf("expected uncompressed payload, got gzip magic") + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("uncompressed payload should be JSON: %v\nraw=%s", err, string(data)) + } + if got["short_message"] != "plain" { + t.Errorf("short_message=%v", got["short_message"]) + } +} + +// TestDatagramGZIPEnabled garante que GZIP default produz datagrama +// com magic bytes 0x1f 0x8b. +func TestDatagramGZIPEnabled(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + MchLog.LogSubject("info", []byte(`{"message":"compressed"}`), nil) + + buf := make([]byte, 4096) + _ = conn.SetReadDeadline(deadline2s()) + n, _, err := conn.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + data := buf[:n] + if !(len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b) { + t.Fatalf("expected gzip magic, got %x", data[:min(len(data), 4)]) + } +} + +// TestLogSubjectEmptySubjectIgnored garante que subject vazio não +// dispara envio (early return previne datagrama vazio). +func TestLogSubjectEmptySubjectIgnored(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + MchLog.LogSubject("", []byte(`{"message":"x"}`), nil) + + _ = conn.SetReadDeadline(deadline100ms()) + buf := make([]byte, 4096) + if _, _, err := conn.ReadFrom(buf); err == nil { + t.Fatalf("empty subject should not produce a datagram") + } +} + +// TestNilReceiverSafety mostra que chamadas em ponteiro nil (caso V3 +// dispatch antes de Initialize) não panicam. +func TestNilReceiverSafety(t *testing.T) { + var g *graylogUDP + g.LogSubject("info", []byte(`{"message":"x"}`), nil) // não deve panicar + if got := g.GetFileNameFromStreamName("info"); got != "" { + t.Errorf("nil GetFileNameFromStreamName=%q want empty", got) + } + if err := g.Close(); err != nil { + t.Errorf("nil Close=%v want nil", err) + } +} + +// TestContentToMapStringJSON exercita a branch de string contendo JSON. +func TestContentToMapStringJSON(t *testing.T) { + got, err := contentToMap(`{"k":"v","n":1}`) + if err != nil { + t.Fatalf("string JSON: %v", err) + } + if got["k"] != "v" { + t.Errorf("k=%v", got["k"]) + } +} + +// TestContentToMapReflectFallback exercita o fallback via reflect para +// mapas com value type estático. +func TestContentToMapReflectFallback(t *testing.T) { + got, err := contentToMap(map[string]int{"n": 42}) + if err != nil { + t.Fatalf("reflect fallback: %v", err) + } + if v, _ := got["n"].(int); v != 42 { + t.Errorf("n=%v", got["n"]) + } +} + +// TestContentToMapUnsupportedType garante erro claro para tipos fora +// do contrato (ex.: int). +func TestContentToMapUnsupportedType(t *testing.T) { + if _, err := contentToMap(123); err == nil { + t.Fatalf("expected error for unsupported type") + } +} + +// TestContentToMapNil garante erro para nil content. +func TestContentToMapNil(t *testing.T) { + if _, err := contentToMap(nil); err == nil { + t.Fatalf("expected error for nil content") + } +} + +// TestServiceFromPathVariants cobre formatos comuns de path. +func TestServiceFromPathVariants(t *testing.T) { + cases := map[string]string{ + "/applog/payments-api/": "payments-api", + "/applog/svc": "svc", + "./applog/svc/": "svc", + "": "", + "/": "", + } + for in, want := range cases { + if got := serviceFromPath(in); got != want { + t.Errorf("serviceFromPath(%q)=%q want %q", in, got, want) + } + } +} + +// TestStringifyNonString garante que stringify converte tipos não-string. +func TestStringifyNonString(t *testing.T) { + if got := stringify(42); got != "42" { + t.Errorf("stringify(42)=%q want 42", got) + } + if got := stringify(nil); got != "" { + t.Errorf("stringify(nil)=%q", got) + } +} From 7333dae7894720c06383de641d897b22558b4321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 20:06:53 -0300 Subject: [PATCH 08/18] test(mchlogcorev3): cover failure handling and rate-limited warn V3 coverage rises to 95.4%. warnOnce reaches 100%. Tests force buildGELFMessage to fail (sending an int as content, which contentToMap rejects) since UDP writes themselves are fire-and-forget and rarely surface errors to the caller. - LogSubject does not panic on a build failure. - Stderr captured via os.Pipe: 100 consecutive failures inside the warn window produce exactly one "GELF UDP send failed" line. - After the warn window expires (test resets lastWarn directly, same package access), a new failure produces a new line. - Initialize without prior Configure returns an error mentioning Configure so callers know what to fix. - Initialize with an empty path errors out instead of running with a blank service name. --- mchlogcorev3/failure_test.go | 148 +++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 mchlogcorev3/failure_test.go diff --git a/mchlogcorev3/failure_test.go b/mchlogcorev3/failure_test.go new file mode 100644 index 0000000..17e9ed2 --- /dev/null +++ b/mchlogcorev3/failure_test.go @@ -0,0 +1,148 @@ +package mchlogcorev3 + +import ( + "bytes" + "io" + "os" + "strings" + "sync" + "testing" + "time" +) + +// captureStderr substitui os.Stderr por uma pipe enquanto fn roda e +// devolve tudo que foi escrito. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + original := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + + var ( + buf bytes.Buffer + wg sync.WaitGroup + ) + wg.Add(1) + go func() { + defer wg.Done() + _, _ = io.Copy(&buf, r) + }() + + fn() + + _ = w.Close() + wg.Wait() + os.Stderr = original + return buf.String() +} + +// TestSendFailureDoesNotPanic garante que um content inválido (que faz +// buildGELFMessage falhar) não panica e silencia o erro para o caller. +func TestSendFailureDoesNotPanic(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("LogSubject panicked: %v", r) + } + }() + // 123 (int) é tipo não suportado por contentToMap. + MchLog.LogSubject("info", 123, nil) +} + +// TestRateLimitedWarnOneLinePerWindow garante que 100 falhas de envio +// dentro da janela produzem exatamente uma linha em stderr. +func TestRateLimitedWarnOneLinePerWindow(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + got := captureStderr(t, func() { + for i := 0; i < 100; i++ { + MchLog.LogSubject("info", 123, nil) // builder fails + } + }) + + count := strings.Count(got, "GELF UDP send failed") + if count != 1 { + t.Errorf("expected 1 warn line in window, got %d. stderr:\n%s", count, got) + } +} + +// TestRateLimitedWarnEmitsAgainAfterWindow garante que após a janela +// expirar, um novo aviso é emitido. +func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { + t.Cleanup(resetConfig) + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + got := captureStderr(t, func() { + MchLog.LogSubject("info", 123, nil) // 1ª falha → warn + // força janela a "expirar" zerando lastWarn (mesmo pacote, OK). + MchLog.mu.Lock() + MchLog.lastWarn = time.Time{} + MchLog.mu.Unlock() + MchLog.LogSubject("info", 123, nil) // 2ª falha → novo warn + }) + + if c := strings.Count(got, "GELF UDP send failed"); c != 2 { + t.Errorf("expected 2 warn lines after window reset, got %d. stderr:\n%s", c, got) + } +} + +// TestNotConfiguredErrorMessage garante que o erro de Initialize sem +// Configure prévio menciona Configure (mensagem orientativa). +func TestNotConfiguredErrorMessage(t *testing.T) { + t.Cleanup(resetConfig) + resetConfig() + + err := Initialize("/applog/svc/") + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "Configure") { + t.Errorf("error should mention Configure: %q", err.Error()) + } +} + +// TestInitializeBadServicePath garante que um path do qual não dá para +// extrair nome de serviço resulta em erro claro. +func TestInitializeBadServicePath(t *testing.T) { + t.Cleanup(resetConfig) + + if err := Configure(NetworkConfig{Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize(""); err == nil { + t.Fatalf("expected error for empty path") + } +} From dea2eabd828917b737e16cc686705f31ab69410a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 20:07:38 -0300 Subject: [PATCH 09/18] test(mchlogcorev3): add opt-in integration test scaffold Adds a //go:build integration test that sends a GELF datagram to a real Graylog instance. It is excluded from the default test suite so CI does not require external infrastructure. To run locally: docker run -d --name graylog-test -p 12201:12201/udp graylog/graylog:5.0 GRAYLOG_TEST_ADDR=localhost:12201 go test -tags=integration -v ./mchlogcorev3/... The test skips itself when GRAYLOG_TEST_ADDR is unset so the tagged build still passes on machines without Graylog running. --- mchlogcorev3/integration_test.go | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 mchlogcorev3/integration_test.go diff --git a/mchlogcorev3/integration_test.go b/mchlogcorev3/integration_test.go new file mode 100644 index 0000000..38b7078 --- /dev/null +++ b/mchlogcorev3/integration_test.go @@ -0,0 +1,42 @@ +//go:build integration + +// Integration tests envia logs reais para um Graylog acessível via UDP. +// É opt-in via build tag para não rodar no CI padrão. Requer a env +// GRAYLOG_TEST_ADDR (ex.: "localhost:12201"). +// +// Exemplo de execução local com docker: +// docker run -d --name graylog-test -p 12201:12201/udp graylog/graylog:5.0 +// GRAYLOG_TEST_ADDR=localhost:12201 go test -tags=integration -v ./mchlogcorev3/... + +package mchlogcorev3 + +import ( + "os" + "testing" +) + +func TestIntegrationSendsToRealGraylog(t *testing.T) { + addr := os.Getenv("GRAYLOG_TEST_ADDR") + if addr == "" { + t.Skip("GRAYLOG_TEST_ADDR not set; skipping integration test") + } + + t.Cleanup(resetConfig) + + if err := Configure(NetworkConfig{ + Addr: addr, + Source: "mchlog-integration-test", + }); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/mchlog-test/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + payload := []byte(`{"message":"integration smoke","level":"info","source":"integration_test.go","line":"1","trace":""}`) + MchLog.LogSubject("info", payload, nil) + // UDP é fire-and-forget; a ausência de panic/erro no caller já é + // confirmação suficiente. A presença real no Graylog deve ser + // inspecionada manualmente na UI. +} From 4eca75182f2647d251c8076110d48677a4d96bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 20:08:24 -0300 Subject: [PATCH 10/18] docs(readme): add V3 (Graylog UDP) usage section Documents the new network backend, including: - Code example for switching to V3 (Configure + SetVersion). - NetworkConfig field reference (Addr, Source, Protocol, DisableGZIP) with the rule that Source is required and caller-provided. - Field mapping table from toolkit payload to GELF wire format to Graylog UI columns (host/source, _application_name, _log_id, _level_name, _file, _line, _error). - Example Graylog searches that exercise application_name, log_id, and source. - Failure handling note: silent drop + rate-limited stderr warn, no automatic file fallback. - Guidance on when NOT to use V3 (production should keep V1/V2). --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b1785da..0ca6723 100644 --- a/README.md +++ b/README.md @@ -131,4 +131,70 @@ if err != nil { logger.Fatal("Error connecting to mysql: " + err.Error()) os.Exit(1) } -``` \ No newline at end of file +``` + +## Envio para Graylog (V3 - Rede) +Para ambientes de **dev/qa**, é possível trocar o backend de arquivo por envio direto a um Graylog via **GELF UDP**. +A API pública do logger não muda: serviços já existentes (que usam V1 por default) seguem funcionando sem alteração. + +Para ativar V3, o serviço configura o destino, troca a versão e inicializa normalmente: +```go +import ( + "os" + + mchlogtoolkitgo "github.com/gaudiumsoftware/mchlogtoolkitgo" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcore" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" +) + +func main() { + if err := mchlogcorev3.Configure(mchlogcorev3.NetworkConfig{ + Addr: "graylog.dev.internal:12201", + Source: "payments-api-qa-" + os.Getenv("POD_NAME"), + // DisableGZIP: true, // opcional, default = compressão habilitada + }); err != nil { + panic(err) + } + mchlogcore.SetVersion(mchlogcore.V3) + + logger, _ := mchlogtoolkitgo.NewLogger("payments-api", "debug") + logger.Initialize() + logger.Info("aplicação iniciada e ouvindo na porta 80") +} +``` + +### Campos do `NetworkConfig` +| Campo | Obrigatório | Descrição | +|---------------|-------------|--------------------------------------------------------------------------------------| +| `Addr` | sim | Endereço do Graylog no formato `host:porta`. | +| `Source` | sim | Valor do campo GELF `host` (coluna `source` no Graylog). É **fornecido pelo serviço** — a toolkit não autodetecta. Ex.: `payments-api-qa-pod-7f8d2`. Use `mchlogcorev3.DefaultSource()` se quiser apenas o hostname. | +| `Protocol` | não | Default `mchlogcorev3.ProtocolGraylogUDP`. | +| `DisableGZIP` | não | Default `false` (gzip habilitado). | + +### Como aparece no Graylog +| GELF field | Origem | Coluna/campo no Graylog | +|---------------------|----------------------------------------------|-------------------------| +| `host` | `cfg.Source` | `source` (default) | +| `short_message` | chave `message` do payload | `message` (default) | +| `level` | severity syslog (info=6, debug=7, warn=4, error=3, fatal=2) | `level` | +| `_application_name` | parâmetro `service` de `NewLogger` | `application_name` | +| `_log_id` | `-mchlog-` (espelha pasta dos arquivos V1/V2) | `log_id` | +| `_level_name` | level em texto | `level_name` | +| `_file`, `_line` | `runtime.Caller` | `file`, `line` | +| `_error` | `errLog.Error()`, quando presente | `error` | + +Exemplos de busca: +- `application_name:payments-api AND level:<=3` — erros de um serviço. +- `log_id:payments-api-mchlog-info` — equivale ao arquivo `INFO` da V1/V2. +- `source:*-qa-*` — todos os pods de QA (env embutido em `Source` pelo caller). + +### Falhas de envio +UDP é fire-and-forget. Se o destino estiver inacessível, a toolkit +**descarta a mensagem silenciosamente** e emite no máximo **uma linha em +`stderr` a cada 60s** (`mchlogcorev3: GELF UDP send failed: ...`). +Não há fallback automático para arquivo. + +### Quando NÃO usar +- Em produção, mantenha V1 (default) ou V2: arquivos persistidos em + `/applog//...` continuam sendo a fonte de verdade. +- V3 é destinado a `dev` e `qa` para concentrar logs no Graylog. \ No newline at end of file From 766763e7c1d54e9cdfe03e1ea745c92ac68a2d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 20:09:08 -0300 Subject: [PATCH 11/18] style(mchlogcorev3): gofmt coverage_test.go Aligns whitespace per gofmt -l on the helper functions and struct field declarations introduced in the coverage tests. --- mchlogcorev3/coverage_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go index 6d83951..1e467d5 100644 --- a/mchlogcorev3/coverage_test.go +++ b/mchlogcorev3/coverage_test.go @@ -8,7 +8,7 @@ import ( "github.com/Graylog2/go-gelf/gelf" ) -func deadline2s() time.Time { return time.Now().Add(2 * time.Second) } +func deadline2s() time.Time { return time.Now().Add(2 * time.Second) } func deadline100ms() time.Time { return time.Now().Add(100 * time.Millisecond) } // TestDatagramLevelMappingAllLevels percorre cada level da toolkit e @@ -16,7 +16,7 @@ func deadline100ms() time.Time { return time.Now().Add(100 * time.Millisecond) } // e o _level_name textual correspondente. func TestDatagramLevelMappingAllLevels(t *testing.T) { cases := []struct { - level string + level string wantSyslog int32 }{ {"fatal", gelf.LOG_CRIT}, From 49802fe510d13d7e32c62b38a312745a0eb27b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 21:30:21 -0300 Subject: [PATCH 12/18] refactor(mchlogcorev3)!: rename NetworkConfig to BackendConfig and restructure MchLog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 was scoped too narrowly to network. Extends it to a unified backend that selects between protocols at Configure time. This is the foundation for adding ProtocolFile in the next commit and eventually retiring V1/V2 once services migrate. Public surface changes: * NetworkConfig -> BackendConfig (still in mchlogcorev3 package). * MchLog: was *graylogUDP global; now LogType (struct) holding an internal backend strategy populated by Initialize. The methods on *LogType (LogSubject, GetFileNameFromStreamName, Close) delegate via a RWMutex-guarded interface field, so the public API remains identical for callers. * mchlogcore: V3 dispatch returns &mchlogcorev3.MchLog (struct address) instead of the old *graylogUDP pointer. Validation in Configure now switches on Protocol: * ProtocolFile (new default) requires nothing. * ProtocolGraylogUDP requires Addr and Source. * Unknown Protocol returns an explicit error. graylogUDP becomes an internal type (still nil-receiver safe). warnOnce, mu, lastWarn are now reachable via type assertion in tests. All existing tests updated to pass Protocol: ProtocolGraylogUDP explicitly. config_test.go rewritten around the new defaults. BREAKING CHANGE: mchlogcorev3.NetworkConfig renamed to BackendConfig (unreleased — only affects in-flight V3 callers on this branch). --- mchlogcore/mchlog.go | 25 +++--- mchlogcore/v3_dispatch_test.go | 7 +- mchlogcorev3/config.go | 91 ++++++++++++--------- mchlogcorev3/config_test.go | 104 +++++++++++++---------- mchlogcorev3/coverage_test.go | 10 +-- mchlogcorev3/failure_test.go | 24 ++++-- mchlogcorev3/file.go | 47 +++++++++++ mchlogcorev3/gelf.go | 2 +- mchlogcorev3/gelf_test.go | 14 ++-- mchlogcorev3/integration_test.go | 7 +- mchlogcorev3/mchlogv3.go | 136 ++++++++++++++++++++++--------- mchlogcorev3/mchlogv3_test.go | 11 +-- 12 files changed, 312 insertions(+), 166 deletions(-) create mode 100644 mchlogcorev3/file.go diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index 1fb142c..deefe81 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -24,9 +24,10 @@ const ( V1 LogVersion = iota // V2 — backend de arquivo, formato simples (um arquivo por subject). V2 - // V3 — backend de rede (família). Protocolos suportados são - // configurados via mchlogcorev3.NetworkConfig.Protocol; primeiro - // protocolo entregue é GELF UDP para Graylog. + // V3 — backend unificado. Suporta arquivo (mesmo layout do V2) e + // rede (GELF UDP) selecionados via mchlogcorev3.BackendConfig.Protocol. + // Outros protocolos (graylog-tcp, syslog, splunk-hec, ...) podem ser + // adicionados sem bumpar o enum. V3 ) @@ -45,15 +46,14 @@ func init() { // Centraliza o dispatch: adicionar uma nova versão significa estender // apenas este switch. // -// Para V3, devolvemos a global mchlogcorev3.MchLog. Antes de -// mchlogcorev3.Initialize esse ponteiro é nil; chamadas via interface -// permanecem seguras porque os métodos de *graylogUDP toleram receiver -// nil. Após Initialize, transportFor é re-invocado por InitializeMchLog -// para refletir o novo ponteiro. +// Para V3, devolvemos &mchlogcorev3.MchLog. O facade interno do V3 +// (LogType) tolera estado pré-Initialize (early-return em LogSubject / +// GetFileNameFromStreamName / Close), então chamadas antes de +// InitializeMchLog não panicam. func transportFor(v LogVersion) Transport { switch v { case V3: - return mchlogcorev3.MchLog + return &mchlogcorev3.MchLog case V2: return &mchlogcorev2.MchLog default: @@ -105,9 +105,10 @@ func (l *LogType) Close() error { var MchLog LogType // InitializeMchLog inicializa o backend selecionado com o caminho dado. -// Para backends de arquivo (V1, V2) o path é o diretório base. Para V3 -// (rede), o path é usado apenas para extrair o nome do serviço (último -// segmento, no formato "//"). +// Em todos os backends o path tem a forma "//": +// - V1, V2 e V3-ProtocolFile usam o caminho como diretório base de arquivos. +// - V3-ProtocolGraylogUDP usa o último segmento apenas para extrair +// o nome do serviço; o destino real é cfg.Addr. func InitializeMchLog(path string) { var versionName string var initErr error diff --git a/mchlogcore/v3_dispatch_test.go b/mchlogcore/v3_dispatch_test.go index 68ec44c..64ee38e 100644 --- a/mchlogcore/v3_dispatch_test.go +++ b/mchlogcore/v3_dispatch_test.go @@ -54,9 +54,10 @@ func TestSetVersionV3DispatchesToGraylog(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := mchlogcorev3.Configure(mchlogcorev3.NetworkConfig{ - Addr: addr, - Source: "pod-1", + if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + Protocol: mchlogcorev3.ProtocolGraylogUDP, + Addr: addr, + Source: "pod-1", }); err != nil { t.Fatalf("Configure: %v", err) } diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index b3d69e7..838bfb2 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -1,7 +1,13 @@ -// Package mchlogcorev3 implementa o backend de rede da toolkit. -// Atualmente entrega logs via GELF UDP para o Graylog. Outros protocolos -// (graylog-tcp, syslog, splunk-hec, etc.) podem ser adicionados expondo -// novos valores de Protocol e a implementação correspondente. +// Package mchlogcorev3 é o backend unificado da toolkit. Suporta múltiplos +// protocolos selecionados por BackendConfig.Protocol: +// +// - ProtocolFile: grava em arquivo no mesmo layout do mchlogcorev2 +// (///.log) e mesma JSON shape. +// - ProtocolGraylogUDP: envia em formato GELF via UDP para o Graylog. +// +// Novos protocolos (graylog-tcp, syslog, splunk-hec, etc.) podem ser +// adicionados expondo novos valores de Protocol e a implementação +// correspondente; a API pública não muda. package mchlogcorev3 import ( @@ -10,57 +16,65 @@ import ( "sync" ) -// Protocol identifica o protocolo de rede usado para enviar logs. -// Adicionar um novo protocolo significa criar uma nova constante e -// estender o dispatch interno; o resto da API pública não muda. +// Protocol identifica o backend efetivo usado para persistir/enviar logs. type Protocol string const ( + // ProtocolFile grava logs em arquivo. Layout e JSON shape são os + // mesmos do mchlogcorev2; o caller controla o caminho via + // Logger.SetPath (ou usa o default /applog/). + ProtocolFile Protocol = "file" + // ProtocolGraylogUDP envia logs em formato GELF via UDP. ProtocolGraylogUDP Protocol = "graylog-udp" ) -// NetworkConfig agrupa parâmetros de transporte de rede. -// Addr e Source são obrigatórios. Source é fornecido pelo serviço -// consumidor (tipicamente o nome do pod ou uma composição como -// "--"); a toolkit não tenta autodetectar. -type NetworkConfig struct { - // Protocol é o protocolo de rede. Default: ProtocolGraylogUDP. +// BackendConfig agrupa todos os parâmetros aceitos pelo V3. Os campos +// relevantes dependem de Protocol — campos de outros protocolos são +// ignorados pela validação. +type BackendConfig struct { + // Protocol seleciona o backend. Default: ProtocolFile. Protocol Protocol - // Addr é o endereço do destino no formato "host:porta". Obrigatório. + + // Addr é o endereço do destino no formato "host:porta". + // Obrigatório quando Protocol = ProtocolGraylogUDP. Addr string - // Source é o valor que será gravado no campo GELF "host" - // (a coluna "source" no Graylog). Obrigatório, fornecido pelo caller. + + // Source é o valor gravado no campo GELF "host" (coluna "source" + // no Graylog). Obrigatório quando Protocol = ProtocolGraylogUDP. + // Fornecido pelo serviço (a toolkit não autodetecta). Source string - // DisableGZIP desabilita a compressão GZIP do GELF UDP. - // Por padrão (zero value), GZIP fica habilitado. + + // DisableGZIP desabilita a compressão GZIP do GELF UDP. Default + // (zero value) = GZIP habilitado. Aplica apenas a ProtocolGraylogUDP. DisableGZIP bool } var ( cfgMu sync.RWMutex - activeCfg NetworkConfig + activeCfg BackendConfig configured bool ) -// Configure normaliza e armazena a configuração de rede que será usada -// pelo transporte. Aplica defaults a Protocol; valida campos obrigatórios. -// Retorna erro se Addr ou Source estiverem vazios, ou se Protocol for -// desconhecido. -func Configure(cfg NetworkConfig) error { - if cfg.Addr == "" { - return errors.New("mchlogcorev3: Addr is required") - } - if cfg.Source == "" { - return errors.New("mchlogcorev3: Source is required (caller-provided)") - } - +// Configure normaliza e armazena a configuração que será usada pelo +// backend. Aplica default a Protocol e valida os campos obrigatórios +// para o protocolo selecionado. +func Configure(cfg BackendConfig) error { if cfg.Protocol == "" { - cfg.Protocol = ProtocolGraylogUDP + cfg.Protocol = ProtocolFile } + switch cfg.Protocol { + case ProtocolFile: + // arquivo: nada obrigatório aqui; o path vem via Logger.SetPath + // e o nome do serviço via NewLogger. case ProtocolGraylogUDP: - // suportado + if cfg.Addr == "" { + return errors.New("mchlogcorev3: Addr is required for ProtocolGraylogUDP") + } + if cfg.Source == "" { + return errors.New("mchlogcorev3: Source is required for ProtocolGraylogUDP (caller-provided)") + } default: return errors.New("mchlogcorev3: unknown Protocol: " + string(cfg.Protocol)) } @@ -73,9 +87,9 @@ func Configure(cfg NetworkConfig) error { } // ActiveConfig retorna uma cópia da configuração ativa. Útil para -// testes e para o transporte ler os parâmetros já normalizados. -// Antes de Configure ser chamado, devolve um NetworkConfig zero-valued. -func ActiveConfig() NetworkConfig { +// testes e para o backend ler os parâmetros já normalizados. +// Antes de Configure ser chamado, devolve um BackendConfig zero-valued. +func ActiveConfig() BackendConfig { cfgMu.RLock() defer cfgMu.RUnlock() return activeCfg @@ -90,7 +104,8 @@ func IsConfigured() bool { // DefaultSource é um helper para callers que não querem compor o Source // manualmente. Devolve o hostname do sistema (os.Hostname) ou "unknown" -// caso a chamada falhe ou retorne string vazia. +// caso a chamada falhe ou retorne string vazia. Útil apenas para +// ProtocolGraylogUDP. func DefaultSource() string { if h, err := os.Hostname(); err == nil && h != "" { return h @@ -102,7 +117,7 @@ func DefaultSource() string { // (não exportado). func resetConfig() { cfgMu.Lock() - activeCfg = NetworkConfig{} + activeCfg = BackendConfig{} configured = false cfgMu.Unlock() } diff --git a/mchlogcorev3/config_test.go b/mchlogcorev3/config_test.go index 52ee19a..3e0ea41 100644 --- a/mchlogcorev3/config_test.go +++ b/mchlogcorev3/config_test.go @@ -13,32 +13,70 @@ func TestDefaultSource(t *testing.T) { } } -// TestConfigureAppliesDefaults garante que Protocol recebe valor default -// quando não especificado e que GZIP fica habilitado por padrão. Source e -// Addr não são auto-preenchidos. -func TestConfigureAppliesDefaults(t *testing.T) { +// TestConfigureDefaultProtocolIsFile garante que sem Protocol explícito +// o default aplicado é ProtocolFile (caminho de menor surpresa para +// callers migrando de V2). +func TestConfigureDefaultProtocolIsFile(t *testing.T) { t.Cleanup(resetConfig) - cfg := NetworkConfig{ - Addr: "graylog.dev:12201", - Source: "svc-x", + if err := Configure(BackendConfig{}); err != nil { + t.Fatalf("Configure with empty config should be valid for file: %v", err) } - if err := Configure(cfg); err != nil { - t.Fatalf("Configure failed: %v", err) + if got := ActiveConfig().Protocol; got != ProtocolFile { + t.Errorf("default Protocol = %q, want %q", got, ProtocolFile) + } +} + +// TestConfigureFileNoRequiredFields garante que ProtocolFile não exige +// Addr nem Source (essas são exclusivas do GraylogUDP). +func TestConfigureFileNoRequiredFields(t *testing.T) { + t.Cleanup(resetConfig) + + if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + t.Fatalf("Configure should accept file with no fields: %v", err) } +} + +// TestConfigureGraylogUDPRequiresAddr. +func TestConfigureGraylogUDPRequiresAddr(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Source: "svc-x"}) + if err == nil { + t.Fatalf("Configure should reject empty Addr for ProtocolGraylogUDP") + } +} + +// TestConfigureGraylogUDPRequiresSource. +func TestConfigureGraylogUDPRequiresSource(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201"}) + if err == nil { + t.Fatalf("Configure should reject empty Source for ProtocolGraylogUDP") + } +} + +// TestConfigureGraylogUDPHappy garante que o caminho válido aceita config. +func TestConfigureGraylogUDPHappy(t *testing.T) { + t.Cleanup(resetConfig) + if err := Configure(BackendConfig{ + Protocol: ProtocolGraylogUDP, + Addr: "graylog.dev:12201", + Source: "svc-x", + }); err != nil { + t.Fatalf("Configure failed: %v", err) + } got := ActiveConfig() if got.Protocol != ProtocolGraylogUDP { - t.Errorf("Protocol default mismatch: got %q want %q", got.Protocol, ProtocolGraylogUDP) + t.Errorf("Protocol = %q", got.Protocol) } if got.DisableGZIP { t.Errorf("GZIP must be enabled by default (DisableGZIP=false)") } - if got.Source != "svc-x" { - t.Errorf("Source must not be auto-filled: got %q", got.Source) - } if got.Addr != "graylog.dev:12201" { - t.Errorf("Addr mismatch: got %q", got.Addr) + t.Errorf("Addr = %q", got.Addr) } } @@ -47,7 +85,8 @@ func TestConfigureAppliesDefaults(t *testing.T) { func TestConfigureDisableGZIPRespected(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(NetworkConfig{ + if err := Configure(BackendConfig{ + Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201", Source: "svc-x", DisableGZIP: true, @@ -59,38 +98,12 @@ func TestConfigureDisableGZIPRespected(t *testing.T) { } } -// TestConfigureRejectsEmptyAddr garante validação obrigatória de Addr. -func TestConfigureRejectsEmptyAddr(t *testing.T) { - t.Cleanup(resetConfig) - - err := Configure(NetworkConfig{Source: "svc-x"}) - if err == nil { - t.Fatalf("Configure should reject empty Addr") - } -} - -// TestConfigureRejectsEmptySource garante validação obrigatória de Source. -// Source é caller-provided por design (toolkit não autodetecta). -func TestConfigureRejectsEmptySource(t *testing.T) { - t.Cleanup(resetConfig) - - err := Configure(NetworkConfig{Addr: "graylog.dev:12201"}) - if err == nil { - t.Fatalf("Configure should reject empty Source") - } -} - -// TestConfigureRejectsUnknownProtocol garante que protocolos não suportados -// retornem erro (futuro-proofing para quando outros protocolos forem -// adicionados). +// TestConfigureRejectsUnknownProtocol garante futuro-proofing para +// quando outros protocolos forem adicionados. func TestConfigureRejectsUnknownProtocol(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(NetworkConfig{ - Protocol: "graylog-tcp", - Addr: "graylog.dev:12201", - Source: "svc-x", - }) + err := Configure(BackendConfig{Protocol: "graylog-tcp"}) if err == nil { t.Fatalf("Configure should reject unknown protocol") } @@ -106,4 +119,7 @@ func TestActiveConfigBeforeConfigure(t *testing.T) { if got.Addr != "" || got.Source != "" { t.Errorf("ActiveConfig before Configure should be zero-valued, got %+v", got) } + if IsConfigured() { + t.Errorf("IsConfigured should be false before Configure") + } } diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go index 1e467d5..b88cd03 100644 --- a/mchlogcorev3/coverage_test.go +++ b/mchlogcorev3/coverage_test.go @@ -33,7 +33,7 @@ func TestDatagramLevelMappingAllLevels(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -69,7 +69,7 @@ func TestDatagramGZIPDisabled(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -85,7 +85,7 @@ func TestDatagramGZIPDisabled(t *testing.T) { // um listener próprio. addr2, conn2 := listenUDP(t) defer conn2.Close() - if err := Configure(NetworkConfig{Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { t.Fatalf("Configure: %v", err) } _ = MchLog.Close() @@ -120,7 +120,7 @@ func TestDatagramGZIPEnabled(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -149,7 +149,7 @@ func TestLogSubjectEmptySubjectIgnored(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { diff --git a/mchlogcorev3/failure_test.go b/mchlogcorev3/failure_test.go index 17e9ed2..ebe86e0 100644 --- a/mchlogcorev3/failure_test.go +++ b/mchlogcorev3/failure_test.go @@ -46,7 +46,7 @@ func TestSendFailureDoesNotPanic(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -70,7 +70,7 @@ func TestRateLimitedWarnOneLinePerWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -97,7 +97,7 @@ func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -107,10 +107,18 @@ func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { got := captureStderr(t, func() { MchLog.LogSubject("info", 123, nil) // 1ª falha → warn - // força janela a "expirar" zerando lastWarn (mesmo pacote, OK). - MchLog.mu.Lock() - MchLog.lastWarn = time.Time{} - MchLog.mu.Unlock() + // força janela a "expirar" zerando lastWarn no backend interno + // (mesmo pacote, acesso a campo unexported permitido). + MchLog.mu.RLock() + impl := MchLog.impl + MchLog.mu.RUnlock() + g, ok := impl.(*graylogUDP) + if !ok { + t.Fatalf("expected *graylogUDP, got %T", impl) + } + g.mu.Lock() + g.lastWarn = time.Time{} + g.mu.Unlock() MchLog.LogSubject("info", 123, nil) // 2ª falha → novo warn }) @@ -139,7 +147,7 @@ func TestNotConfiguredErrorMessage(t *testing.T) { func TestInitializeBadServicePath(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(NetworkConfig{Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize(""); err == nil { diff --git a/mchlogcorev3/file.go b/mchlogcorev3/file.go new file mode 100644 index 0000000..b01954d --- /dev/null +++ b/mchlogcorev3/file.go @@ -0,0 +1,47 @@ +package mchlogcorev3 + +import ( + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" +) + +// fileBackend é a estratégia de arquivo do V3. Internamente delega +// para mchlogcorev2.MchLog, preservando integralmente o layout +// (///.log) e a JSON shape do V2. +// +// É um wrapper fino de propósito: permite ao V3 oferecer "mesmo +// comportamento do V2" sem duplicar código, abrindo o caminho para +// que serviços migrem de V2 para V3 sem alterar a operação. Quando +// V1/V2 forem removidos no futuro, a lógica do V2 pode ser inlineada +// aqui sem mudar a API pública do V3. +type fileBackend struct { + inner *mchlogcorev2.LogType +} + +// newFileBackend inicializa o V2 subjacente com o path recebido e +// devolve um wrapper pronto para uso. +func newFileBackend(path string) *fileBackend { + mchlogcorev2.InitializeMchLog(path) + return &fileBackend{inner: &mchlogcorev2.MchLog} +} + +func (f *fileBackend) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { + if f == nil || f.inner == nil { + return + } + f.inner.LogSubject(subject, content, errLog, ascendStackFrame...) +} + +func (f *fileBackend) GetFileNameFromStreamName(subject string) string { + if f == nil || f.inner == nil { + return "" + } + return f.inner.GetFileNameFromStreamName(subject) +} + +// Close é no-op porque mchlogcorev2.LogType não expõe Close (decisão +// prévia de não alterar V1/V2). O sistema operacional libera os FDs +// no encerramento do processo, comportamento idêntico ao uso direto +// de mchlogcorev2. +func (f *fileBackend) Close() error { + return nil +} diff --git a/mchlogcorev3/gelf.go b/mchlogcorev3/gelf.go index 75733c6..4192ce2 100644 --- a/mchlogcorev3/gelf.go +++ b/mchlogcorev3/gelf.go @@ -63,7 +63,7 @@ func levelToSyslog(level string) int32 { // - _trace = chave "trace" // - demais chaves = prefixadas com "_" (a menos que reservadas) // - _error = errLog.Error() quando errLog != nil -func buildGELFMessage(serviceName, level string, content any, errLog error, cfg NetworkConfig) (*gelf.Message, error) { +func buildGELFMessage(serviceName, level string, content any, errLog error, cfg BackendConfig) (*gelf.Message, error) { fields, err := contentToMap(content) if err != nil { return nil, err diff --git a/mchlogcorev3/gelf_test.go b/mchlogcorev3/gelf_test.go index bc069f6..5c797a9 100644 --- a/mchlogcorev3/gelf_test.go +++ b/mchlogcorev3/gelf_test.go @@ -41,7 +41,7 @@ func TestLevelToSyslogUnknownDefaultsToInfo(t *testing.T) { // que reproduz a saída do formatLog do logger.go. func TestBuildGELFMessageRequiredFields(t *testing.T) { payload := []byte(`{"message":"hello","level":"info","source":"foo.go","line":"42","trace":""}`) - msg, err := buildGELFMessage("payments-api", "info", payload, nil, NetworkConfig{ + msg, err := buildGELFMessage("payments-api", "info", payload, nil, BackendConfig{ Source: "pod-1", }) if err != nil { @@ -68,7 +68,7 @@ func TestBuildGELFMessageRequiredFields(t *testing.T) { // _application_name, _log_id, _level_name, _file e _line. func TestBuildGELFMessageCustomFields(t *testing.T) { payload := []byte(`{"message":"hi","level":"debug","source":"internal/foo.go","line":"99","trace":"abc"}`) - msg, err := buildGELFMessage("payments-api", "debug", payload, nil, NetworkConfig{ + msg, err := buildGELFMessage("payments-api", "debug", payload, nil, BackendConfig{ Source: "pod-1", }) if err != nil { @@ -99,7 +99,7 @@ func TestBuildGELFMessageCustomFields(t *testing.T) { // _error e mantém os demais campos. func TestBuildGELFMessageWithError(t *testing.T) { payload := []byte(`{"message":"boom","level":"error","source":"x.go","line":"7","trace":""}`) - msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), NetworkConfig{ + msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), BackendConfig{ Source: "pod-1", }) if err != nil { @@ -124,7 +124,7 @@ func TestBuildGELFMessageAcceptsMap(t *testing.T) { "message": "MchLogToolkit initialized", "version": "V3", } - msg, err := buildGELFMessage("svc", "info", content, nil, NetworkConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", content, nil, BackendConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -140,7 +140,7 @@ func TestBuildGELFMessageAcceptsMap(t *testing.T) { // "message" usa string vazia em Short e não falha. func TestBuildGELFMessageMissingMessage(t *testing.T) { payload := []byte(`{"level":"info"}`) - msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -153,7 +153,7 @@ func TestBuildGELFMessageMissingMessage(t *testing.T) { // malformado produz erro em vez de panic. func TestBuildGELFMessageInvalidJSONReturnsError(t *testing.T) { payload := []byte(`{not json`) - if _, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}); err == nil { + if _, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}); err == nil { t.Fatalf("expected error on invalid JSON") } } @@ -162,7 +162,7 @@ func TestBuildGELFMessageInvalidJSONReturnsError(t *testing.T) { // serializa em JSON válido com Extra inline (formato exigido pelo GELF). func TestBuildGELFMessageSerializable(t *testing.T) { payload := []byte(`{"message":"x","level":"info","source":"a.go","line":"1","trace":""}`) - msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } diff --git a/mchlogcorev3/integration_test.go b/mchlogcorev3/integration_test.go index 38b7078..c80ebb7 100644 --- a/mchlogcorev3/integration_test.go +++ b/mchlogcorev3/integration_test.go @@ -23,9 +23,10 @@ func TestIntegrationSendsToRealGraylog(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(NetworkConfig{ - Addr: addr, - Source: "mchlog-integration-test", + if err := Configure(BackendConfig{ + Protocol: ProtocolGraylogUDP, + Addr: addr, + Source: "mchlog-integration-test", }); err != nil { t.Fatalf("Configure: %v", err) } diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go index c37f4ba..3222339 100644 --- a/mchlogcorev3/mchlogv3.go +++ b/mchlogcorev3/mchlogv3.go @@ -13,30 +13,73 @@ import ( ) // warnWindow é a janela de rate-limit das mensagens de aviso impressas -// no stderr quando o envio para o Graylog falha. Uma falha gera no -// máximo um aviso por janela. +// no stderr quando o envio falha. Uma falha gera no máximo um aviso +// por janela. const warnWindow = 60 * time.Second -// graylogUDP implementa a interface mchlogcore.Transport (e Closer) -// enviando logs em formato GELF via UDP para um servidor Graylog. -type graylogUDP struct { - writer *gelf.Writer - cfg NetworkConfig - serviceName string +// backend é a estratégia interna do V3: implementações concretas +// (graylogUDP, fileBackend) atendem este contrato e são selecionadas +// por Protocol em Initialize. +type backend interface { + LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) + GetFileNameFromStreamName(subject string) string + Close() error +} - mu sync.Mutex - closed bool - lastWarn time.Time +// LogType é o facade público do V3. Mantém uma estratégia interna +// (file ou rede) escolhida por Protocol e delega todas as chamadas. +// Satisfaz mchlogcore.Transport e mchlogcore.Closer. +type LogType struct { + mu sync.RWMutex + impl backend +} + +// MchLog é a instância global do V3. É populada por Initialize. +// Antes de Initialize a estratégia interna é nil; chamadas via +// Transport permanecem seguras (early-return). +var MchLog LogType + +// LogSubject delega para a estratégia ativa. Se ainda não houve +// Initialize, é no-op. +func (l *LogType) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { + l.mu.RLock() + impl := l.impl + l.mu.RUnlock() + if impl == nil { + return + } + impl.LogSubject(subject, content, errLog, ascendStackFrame...) +} + +// GetFileNameFromStreamName delega para a estratégia ativa. Devolve +// "" antes de Initialize. +func (l *LogType) GetFileNameFromStreamName(subject string) string { + l.mu.RLock() + impl := l.impl + l.mu.RUnlock() + if impl == nil { + return "" + } + return impl.GetFileNameFromStreamName(subject) } -// MchLog é a instância global do transporte V3. É populada por -// Initialize. Antes disso, é nil. -var MchLog *graylogUDP +// Close fecha a estratégia ativa. Idempotente: chamadas repetidas +// retornam nil. Após Close, LogSubject torna a ser no-op. +func (l *LogType) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.impl == nil { + return nil + } + err := l.impl.Close() + l.impl = nil + return err +} -// Initialize prepara o transporte V3 para uso. O parâmetro path tem -// a mesma forma usada por V1/V2 ("//") e o nome do -// serviço é extraído do último segmento. Configure precisa ter sido -// chamado antes; caso contrário, retorna erro. +// Initialize prepara o V3 conforme a configuração ativa. O parâmetro +// path tem a mesma forma usada por V1/V2 ("//") +// e o nome do serviço é extraído do último segmento. Configure precisa +// ter sido chamado antes; caso contrário, retorna erro. func Initialize(path string) error { if !IsConfigured() { return errors.New("mchlogcorev3: Configure must be called before Initialize") @@ -49,26 +92,46 @@ func Initialize(path string) error { cfg := ActiveConfig() - w, err := gelf.NewWriter(cfg.Addr) - if err != nil { - return fmt.Errorf("mchlogcorev3: dial GELF UDP %s: %w", cfg.Addr, err) - } - if cfg.DisableGZIP { - w.CompressionType = gelf.CompressNone - } else { - w.CompressionType = gelf.CompressGzip + var impl backend + switch cfg.Protocol { + case ProtocolFile: + impl = newFileBackend(path) + case ProtocolGraylogUDP: + w, err := gelf.NewWriter(cfg.Addr) + if err != nil { + return fmt.Errorf("mchlogcorev3: dial GELF UDP %s: %w", cfg.Addr, err) + } + if cfg.DisableGZIP { + w.CompressionType = gelf.CompressNone + } else { + w.CompressionType = gelf.CompressGzip + } + impl = &graylogUDP{ + writer: w, + cfg: cfg, + serviceName: service, + } + default: + return errors.New("mchlogcorev3: unsupported Protocol: " + string(cfg.Protocol)) } - MchLog = &graylogUDP{ - writer: w, - cfg: cfg, - serviceName: service, - } + MchLog.mu.Lock() + MchLog.impl = impl + MchLog.mu.Unlock() return nil } -// LogSubject monta a mensagem GELF e envia via UDP. Falhas no envio -// são registradas via warnOnce e silenciadas para o caller. +// graylogUDP envia logs em formato GELF via UDP. +type graylogUDP struct { + writer *gelf.Writer + cfg BackendConfig + serviceName string + + mu sync.Mutex + closed bool + lastWarn time.Time +} + func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { if g == nil || subject == "" { return @@ -84,15 +147,9 @@ func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascen g.warnOnce(err) } - // ascendStackFrame é mantido na assinatura por contrato com o - // facade; o caller pode usar para skip no zerolog. V3 já popula - // _file/_line a partir do payload, então é silenciosamente ignorado. _ = ascendStackFrame } -// GetFileNameFromStreamName devolve um descritor lógico do "fluxo" de -// log, no formato "udp:///". Usado apenas para -// observabilidade e compatibilidade com testes existentes. func (g *graylogUDP) GetFileNameFromStreamName(subject string) string { if g == nil { return "" @@ -100,7 +157,6 @@ func (g *graylogUDP) GetFileNameFromStreamName(subject string) string { return "udp://" + g.cfg.Addr + "/" + subject } -// Close fecha o writer GELF. Idempotente. func (g *graylogUDP) Close() error { if g == nil { return nil diff --git a/mchlogcorev3/mchlogv3_test.go b/mchlogcorev3/mchlogv3_test.go index 284ff83..08e1c80 100644 --- a/mchlogcorev3/mchlogv3_test.go +++ b/mchlogcorev3/mchlogv3_test.go @@ -57,9 +57,10 @@ func TestGraylogUDPSendsValidGELF(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{ - Addr: addr, - Source: "pod-1", + if err := Configure(BackendConfig{ + Protocol: ProtocolGraylogUDP, + Addr: addr, + Source: "pod-1", }); err != nil { t.Fatalf("Configure: %v", err) } @@ -106,7 +107,7 @@ func TestGraylogUDPGetFileNameFromStreamName(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/payments-api/"); err != nil { @@ -129,7 +130,7 @@ func TestGraylogUDPCloseIdempotent(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(NetworkConfig{Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { From 350f5686f5164bbc84445954c30919e3103e5687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 21:31:10 -0300 Subject: [PATCH 13/18] test(mchlogcorev3): cover ProtocolFile behavior Confirms V3 with ProtocolFile produces files identical to V2: * Layout: ///.log. * JSON shape: {message, level, source, line, trace, timestamp}. * Errors prefixed with err_, written to err_/err_.log. * GetFileNameFromStreamName returns the real on-disk path (delegated). * Close is a no-op and idempotent (V2 underneath has no Close). These are the contract a service migrating from V2 to V3 with ProtocolFile depends on; the tests pin them explicitly so future internal changes (e.g. inlining V2 logic into V3) cannot regress the on-disk format silently. --- mchlogcorev3/file_test.go | 139 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 mchlogcorev3/file_test.go diff --git a/mchlogcorev3/file_test.go b/mchlogcorev3/file_test.go new file mode 100644 index 0000000..74f4174 --- /dev/null +++ b/mchlogcorev3/file_test.go @@ -0,0 +1,139 @@ +package mchlogcorev3 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestProtocolFileWritesV2LayoutAndShape garante que com ProtocolFile o +// V3 grava o log no caminho ///.log +// (mesmo layout do V2) e usa a mesma JSON shape do V2. +func TestProtocolFileWritesV2LayoutAndShape(t *testing.T) { + t.Cleanup(resetConfig) + + dir := t.TempDir() + if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + t.Fatalf("Configure: %v", err) + } + servicePath := filepath.Join(dir, "payments-api") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + payload := []byte(`{"message":"hello","level":"info","source":"x.go","line":"1","trace":""}`) + MchLog.LogSubject("info", payload, nil) + + expected := MchLog.GetFileNameFromStreamName("info") + if expected == "" { + t.Fatalf("expected non-empty file path") + } + if !strings.Contains(expected, filepath.Join("info", "info.log")) { + t.Errorf("expected V2 layout (.../info/info.log), got %q", expected) + } + + data, err := os.ReadFile(expected) + if err != nil { + t.Fatalf("read log: %v", err) + } + // Cada linha é um JSON (zerolog grava 1 evento por linha). + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + t.Fatalf("no log lines written") + } + var got map[string]any + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &got); err != nil { + t.Fatalf("invalid JSON line: %v\nline=%s", err, lines[len(lines)-1]) + } + // Mesma shape do V2 atual: campos sem prefixo "_". + if got["message"] != "hello" { + t.Errorf("message=%v want hello", got["message"]) + } + if got["level"] != "info" { + t.Errorf("level=%v want info", got["level"]) + } + if got["source"] != "x.go" { + t.Errorf("source=%v", got["source"]) + } + if _, hasTimestamp := got["timestamp"]; !hasTimestamp { + t.Errorf("missing timestamp") + } +} + +// TestProtocolFileErrorPrefixesSubject garante que erros vão para +// pasta err_/, mantendo o comportamento do V2. +func TestProtocolFileErrorPrefixesSubject(t *testing.T) { + t.Cleanup(resetConfig) + + dir := t.TempDir() + if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + t.Fatalf("Configure: %v", err) + } + servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + payload := []byte(`{"message":"boom","level":"error"}`) + MchLog.LogSubject("error", payload, errForTest("kaboom")) + + errPath := filepath.Join(dir, "svc", "err_error", "err_error.log") + if _, err := os.Stat(errPath); err != nil { + t.Fatalf("expected err_error file at %q: %v", errPath, err) + } +} + +// TestProtocolFileGetFileNameFromStreamName devolve caminho real de +// arquivo (delegando ao V2). +func TestProtocolFileGetFileNameFromStreamName(t *testing.T) { + t.Cleanup(resetConfig) + + dir := t.TempDir() + if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + t.Fatalf("Configure: %v", err) + } + servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + got := MchLog.GetFileNameFromStreamName("info") + want := filepath.Join(dir, "svc", "info", "info.log") + if got != want { + t.Errorf("path = %q want %q", got, want) + } +} + +// TestProtocolFileCloseIsNoOp documenta que Close é no-op para V3-file +// (V2 subjacente não expõe Close — decisão prévia). Idempotente. +func TestProtocolFileCloseIsNoOp(t *testing.T) { + t.Cleanup(resetConfig) + + dir := t.TempDir() + if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + t.Fatalf("Configure: %v", err) + } + servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := MchLog.Close(); err != nil { + t.Errorf("Close error: %v", err) + } + // Idempotência: + if err := MchLog.Close(); err != nil { + t.Errorf("second Close error: %v", err) + } +} + +// errForTest é um helper conciso para construir um error. +type fakeErr string + +func (e fakeErr) Error() string { return string(e) } + +func errForTest(msg string) error { return fakeErr(msg) } From 283fc852217b4185bb74a99cb2ddff4bea035935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Mon, 4 May 2026 21:32:06 -0300 Subject: [PATCH 14/18] docs(readme): rewrite V3 section as unified backend (file + Graylog) V3 is now framed as the unified backend, not a network-only family. The section now documents: * Both protocols side by side: ProtocolFile (V2-equivalent layout and JSON) and ProtocolGraylogUDP (GELF over UDP). * BackendConfig field reference clarifying which fields apply to which protocol (Addr/Source/DisableGZIP only for GraylogUDP). * Graylog field mapping table kept for the network mode. * "When to use each mode" guidance: ProtocolFile for production, ProtocolGraylogUDP for dev/qa. * Migration note from V1/V2 to V3 with ProtocolFile (no behavior diff). V1/V2 stay available for now. --- README.md | 73 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0ca6723..b2f7ab6 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,36 @@ if err != nil { } ``` -## Envio para Graylog (V3 - Rede) -Para ambientes de **dev/qa**, é possível trocar o backend de arquivo por envio direto a um Graylog via **GELF UDP**. -A API pública do logger não muda: serviços já existentes (que usam V1 por default) seguem funcionando sem alteração. +## V3 - Backend unificado (arquivo ou Graylog) +A V3 é o backend unificado da toolkit. O serviço escolhe entre **arquivo** (mesmo layout do V2) e **GELF UDP** (Graylog) configurando `BackendConfig.Protocol`. A API do `Logger` não muda — serviços que ainda usam V1 (default) ou V2 seguem funcionando sem alteração. -Para ativar V3, o serviço configura o destino, troca a versão e inicializa normalmente: +A V3 é a forma recomendada daqui em diante. V1 e V2 continuam disponíveis para retrocompatibilidade enquanto serviços migram. + +### Modo arquivo (`ProtocolFile`) +Comportamento idêntico ao V2: layout `///.log`, mesma JSON shape (`message`, `level`, `source`, `line`, `trace`, `timestamp`). +```go +import ( + mchlogtoolkitgo "github.com/gaudiumsoftware/mchlogtoolkitgo" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcore" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" +) + +func main() { + if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + Protocol: mchlogcorev3.ProtocolFile, + }); err != nil { + panic(err) + } + mchlogcore.SetVersion(mchlogcore.V3) + + logger, _ := mchlogtoolkitgo.NewLogger("payments-api", "info") + logger.Initialize() // grava em /applog/payments-api/... + logger.Info("aplicação iniciada") +} +``` + +### Modo Graylog UDP (`ProtocolGraylogUDP`) +Para `dev`/`qa` que centralizam logs no Graylog em vez de arquivo local: ```go import ( "os" @@ -148,9 +173,10 @@ import ( ) func main() { - if err := mchlogcorev3.Configure(mchlogcorev3.NetworkConfig{ - Addr: "graylog.dev.internal:12201", - Source: "payments-api-qa-" + os.Getenv("POD_NAME"), + if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + Protocol: mchlogcorev3.ProtocolGraylogUDP, + Addr: "graylog.dev.internal:12201", + Source: "payments-api-qa-" + os.Getenv("POD_NAME"), // DisableGZIP: true, // opcional, default = compressão habilitada }); err != nil { panic(err) @@ -163,38 +189,41 @@ func main() { } ``` -### Campos do `NetworkConfig` -| Campo | Obrigatório | Descrição | -|---------------|-------------|--------------------------------------------------------------------------------------| -| `Addr` | sim | Endereço do Graylog no formato `host:porta`. | -| `Source` | sim | Valor do campo GELF `host` (coluna `source` no Graylog). É **fornecido pelo serviço** — a toolkit não autodetecta. Ex.: `payments-api-qa-pod-7f8d2`. Use `mchlogcorev3.DefaultSource()` se quiser apenas o hostname. | -| `Protocol` | não | Default `mchlogcorev3.ProtocolGraylogUDP`. | -| `DisableGZIP` | não | Default `false` (gzip habilitado). | +### Campos do `BackendConfig` +| Campo | Obrigatório quando… | Descrição | +|---------------|--------------------------------|--------------------------------------------------------------------------------------| +| `Protocol` | — | `ProtocolFile` (default) ou `ProtocolGraylogUDP`. | +| `Addr` | `Protocol = ProtocolGraylogUDP`| Endereço do Graylog no formato `host:porta`. | +| `Source` | `Protocol = ProtocolGraylogUDP`| Valor do campo GELF `host` (coluna `source` no Graylog). **Fornecido pelo serviço** — a toolkit não autodetecta. Ex.: `payments-api-qa-pod-7f8d2`. Use `mchlogcorev3.DefaultSource()` se quiser apenas o hostname. | +| `DisableGZIP` | nunca (opcional) | Default `false` (gzip habilitado). Aplica só ao `ProtocolGraylogUDP`. | -### Como aparece no Graylog +### Como aparece no Graylog (modo `ProtocolGraylogUDP`) | GELF field | Origem | Coluna/campo no Graylog | |---------------------|----------------------------------------------|-------------------------| | `host` | `cfg.Source` | `source` (default) | | `short_message` | chave `message` do payload | `message` (default) | | `level` | severity syslog (info=6, debug=7, warn=4, error=3, fatal=2) | `level` | | `_application_name` | parâmetro `service` de `NewLogger` | `application_name` | -| `_log_id` | `-mchlog-` (espelha pasta dos arquivos V1/V2) | `log_id` | +| `_log_id` | `-mchlog-` (espelha pasta dos arquivos) | `log_id` | | `_level_name` | level em texto | `level_name` | | `_file`, `_line` | `runtime.Caller` | `file`, `line` | | `_error` | `errLog.Error()`, quando presente | `error` | Exemplos de busca: - `application_name:payments-api AND level:<=3` — erros de um serviço. -- `log_id:payments-api-mchlog-info` — equivale ao arquivo `INFO` da V1/V2. +- `log_id:payments-api-mchlog-info` — equivale ao arquivo `INFO`. - `source:*-qa-*` — todos os pods de QA (env embutido em `Source` pelo caller). -### Falhas de envio +### Falhas de envio (modo `ProtocolGraylogUDP`) UDP é fire-and-forget. Se o destino estiver inacessível, a toolkit **descarta a mensagem silenciosamente** e emite no máximo **uma linha em `stderr` a cada 60s** (`mchlogcorev3: GELF UDP send failed: ...`). Não há fallback automático para arquivo. -### Quando NÃO usar -- Em produção, mantenha V1 (default) ou V2: arquivos persistidos em - `/applog//...` continuam sendo a fonte de verdade. -- V3 é destinado a `dev` e `qa` para concentrar logs no Graylog. \ No newline at end of file +### Quando usar cada modo +- **Produção**: `ProtocolFile` (ou seguir em V1/V2). Arquivos persistidos em `/applog//...` são a fonte de verdade. +- **Dev/QA**: `ProtocolGraylogUDP` para concentrar logs no Graylog. + +### Migração de V1/V2 para V3 +Trocar `mchlogcore.SetVersion(mchlogcore.V2)` por `Configure(BackendConfig{Protocol: ProtocolFile}) + SetVersion(V3)` mantém o comportamento bit-a-bit (mesmo layout, mesma JSON shape). +V1 e V2 seguem disponíveis até a próxima onda de migração. \ No newline at end of file From c263e432814dfa3951d34021186d61885b743c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Tue, 5 May 2026 14:04:21 -0300 Subject: [PATCH 15/18] refactor(mchlogcorev3): rename BackendConfig to DestinationConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns terminology with the convention already used by services consuming the toolkit (see mch-log-graylog-go MCH_LOG_DESTINATION and the LOG_DESTINATION env var introduced in MchSyncherCache). "Destination" describes what the field selects (where logs go) better than "Backend". Renames: * BackendConfig -> DestinationConfig (struct) * fileBackend -> fileDestination (internal struct) * newFileBackend -> newFileDestination * internal "backend" interface -> "destination" interface * LogType.impl is typed against the new interface name * README references and test files updated to match No behavior change. All tests still pass. BREAKING CHANGE: BackendConfig renamed to DestinationConfig (unreleased — only affects in-flight V3 callers on this branch). --- README.md | 10 +++++----- mchlogcore/mchlog.go | 2 +- mchlogcore/v3_dispatch_test.go | 2 +- mchlogcorev3/config.go | 16 ++++++++-------- mchlogcorev3/config_test.go | 14 +++++++------- mchlogcorev3/coverage_test.go | 10 +++++----- mchlogcorev3/failure_test.go | 8 ++++---- mchlogcorev3/file.go | 16 ++++++++-------- mchlogcorev3/file_test.go | 8 ++++---- mchlogcorev3/gelf.go | 2 +- mchlogcorev3/gelf_test.go | 14 +++++++------- mchlogcorev3/integration_test.go | 2 +- mchlogcorev3/mchlogv3.go | 14 +++++++------- mchlogcorev3/mchlogv3_test.go | 6 +++--- 14 files changed, 62 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index b2f7ab6..9be62de 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ if err != nil { ``` ## V3 - Backend unificado (arquivo ou Graylog) -A V3 é o backend unificado da toolkit. O serviço escolhe entre **arquivo** (mesmo layout do V2) e **GELF UDP** (Graylog) configurando `BackendConfig.Protocol`. A API do `Logger` não muda — serviços que ainda usam V1 (default) ou V2 seguem funcionando sem alteração. +A V3 é o backend unificado da toolkit. O serviço escolhe entre **arquivo** (mesmo layout do V2) e **GELF UDP** (Graylog) configurando `DestinationConfig.Protocol`. A API do `Logger` não muda — serviços que ainda usam V1 (default) ou V2 seguem funcionando sem alteração. A V3 é a forma recomendada daqui em diante. V1 e V2 continuam disponíveis para retrocompatibilidade enquanto serviços migram. @@ -148,7 +148,7 @@ import ( ) func main() { - if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ Protocol: mchlogcorev3.ProtocolFile, }); err != nil { panic(err) @@ -173,7 +173,7 @@ import ( ) func main() { - if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ Protocol: mchlogcorev3.ProtocolGraylogUDP, Addr: "graylog.dev.internal:12201", Source: "payments-api-qa-" + os.Getenv("POD_NAME"), @@ -189,7 +189,7 @@ func main() { } ``` -### Campos do `BackendConfig` +### Campos do `DestinationConfig` | Campo | Obrigatório quando… | Descrição | |---------------|--------------------------------|--------------------------------------------------------------------------------------| | `Protocol` | — | `ProtocolFile` (default) ou `ProtocolGraylogUDP`. | @@ -225,5 +225,5 @@ Não há fallback automático para arquivo. - **Dev/QA**: `ProtocolGraylogUDP` para concentrar logs no Graylog. ### Migração de V1/V2 para V3 -Trocar `mchlogcore.SetVersion(mchlogcore.V2)` por `Configure(BackendConfig{Protocol: ProtocolFile}) + SetVersion(V3)` mantém o comportamento bit-a-bit (mesmo layout, mesma JSON shape). +Trocar `mchlogcore.SetVersion(mchlogcore.V2)` por `Configure(DestinationConfig{Protocol: ProtocolFile}) + SetVersion(V3)` mantém o comportamento bit-a-bit (mesmo layout, mesma JSON shape). V1 e V2 seguem disponíveis até a próxima onda de migração. \ No newline at end of file diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index deefe81..1e15429 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -25,7 +25,7 @@ const ( // V2 — backend de arquivo, formato simples (um arquivo por subject). V2 // V3 — backend unificado. Suporta arquivo (mesmo layout do V2) e - // rede (GELF UDP) selecionados via mchlogcorev3.BackendConfig.Protocol. + // rede (GELF UDP) selecionados via mchlogcorev3.DestinationConfig.Protocol. // Outros protocolos (graylog-tcp, syslog, splunk-hec, ...) podem ser // adicionados sem bumpar o enum. V3 diff --git a/mchlogcore/v3_dispatch_test.go b/mchlogcore/v3_dispatch_test.go index 64ee38e..b76b7cf 100644 --- a/mchlogcore/v3_dispatch_test.go +++ b/mchlogcore/v3_dispatch_test.go @@ -54,7 +54,7 @@ func TestSetVersionV3DispatchesToGraylog(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := mchlogcorev3.Configure(mchlogcorev3.BackendConfig{ + if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ Protocol: mchlogcorev3.ProtocolGraylogUDP, Addr: addr, Source: "pod-1", diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index 838bfb2..3237009 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -1,5 +1,5 @@ // Package mchlogcorev3 é o backend unificado da toolkit. Suporta múltiplos -// protocolos selecionados por BackendConfig.Protocol: +// protocolos selecionados por DestinationConfig.Protocol: // // - ProtocolFile: grava em arquivo no mesmo layout do mchlogcorev2 // (///.log) e mesma JSON shape. @@ -29,10 +29,10 @@ const ( ProtocolGraylogUDP Protocol = "graylog-udp" ) -// BackendConfig agrupa todos os parâmetros aceitos pelo V3. Os campos +// DestinationConfig agrupa todos os parâmetros aceitos pelo V3. Os campos // relevantes dependem de Protocol — campos de outros protocolos são // ignorados pela validação. -type BackendConfig struct { +type DestinationConfig struct { // Protocol seleciona o backend. Default: ProtocolFile. Protocol Protocol @@ -52,14 +52,14 @@ type BackendConfig struct { var ( cfgMu sync.RWMutex - activeCfg BackendConfig + activeCfg DestinationConfig configured bool ) // Configure normaliza e armazena a configuração que será usada pelo // backend. Aplica default a Protocol e valida os campos obrigatórios // para o protocolo selecionado. -func Configure(cfg BackendConfig) error { +func Configure(cfg DestinationConfig) error { if cfg.Protocol == "" { cfg.Protocol = ProtocolFile } @@ -88,8 +88,8 @@ func Configure(cfg BackendConfig) error { // ActiveConfig retorna uma cópia da configuração ativa. Útil para // testes e para o backend ler os parâmetros já normalizados. -// Antes de Configure ser chamado, devolve um BackendConfig zero-valued. -func ActiveConfig() BackendConfig { +// Antes de Configure ser chamado, devolve um DestinationConfig zero-valued. +func ActiveConfig() DestinationConfig { cfgMu.RLock() defer cfgMu.RUnlock() return activeCfg @@ -117,7 +117,7 @@ func DefaultSource() string { // (não exportado). func resetConfig() { cfgMu.Lock() - activeCfg = BackendConfig{} + activeCfg = DestinationConfig{} configured = false cfgMu.Unlock() } diff --git a/mchlogcorev3/config_test.go b/mchlogcorev3/config_test.go index 3e0ea41..86a3a00 100644 --- a/mchlogcorev3/config_test.go +++ b/mchlogcorev3/config_test.go @@ -19,7 +19,7 @@ func TestDefaultSource(t *testing.T) { func TestConfigureDefaultProtocolIsFile(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{}); err != nil { + if err := Configure(DestinationConfig{}); err != nil { t.Fatalf("Configure with empty config should be valid for file: %v", err) } if got := ActiveConfig().Protocol; got != ProtocolFile { @@ -32,7 +32,7 @@ func TestConfigureDefaultProtocolIsFile(t *testing.T) { func TestConfigureFileNoRequiredFields(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { t.Fatalf("Configure should accept file with no fields: %v", err) } } @@ -41,7 +41,7 @@ func TestConfigureFileNoRequiredFields(t *testing.T) { func TestConfigureGraylogUDPRequiresAddr(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Source: "svc-x"}) + err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Source: "svc-x"}) if err == nil { t.Fatalf("Configure should reject empty Addr for ProtocolGraylogUDP") } @@ -51,7 +51,7 @@ func TestConfigureGraylogUDPRequiresAddr(t *testing.T) { func TestConfigureGraylogUDPRequiresSource(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201"}) + err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201"}) if err == nil { t.Fatalf("Configure should reject empty Source for ProtocolGraylogUDP") } @@ -61,7 +61,7 @@ func TestConfigureGraylogUDPRequiresSource(t *testing.T) { func TestConfigureGraylogUDPHappy(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{ + if err := Configure(DestinationConfig{ Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201", Source: "svc-x", @@ -85,7 +85,7 @@ func TestConfigureGraylogUDPHappy(t *testing.T) { func TestConfigureDisableGZIPRespected(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{ + if err := Configure(DestinationConfig{ Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201", Source: "svc-x", @@ -103,7 +103,7 @@ func TestConfigureDisableGZIPRespected(t *testing.T) { func TestConfigureRejectsUnknownProtocol(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(BackendConfig{Protocol: "graylog-tcp"}) + err := Configure(DestinationConfig{Protocol: "graylog-tcp"}) if err == nil { t.Fatalf("Configure should reject unknown protocol") } diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go index b88cd03..89bf949 100644 --- a/mchlogcorev3/coverage_test.go +++ b/mchlogcorev3/coverage_test.go @@ -33,7 +33,7 @@ func TestDatagramLevelMappingAllLevels(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -69,7 +69,7 @@ func TestDatagramGZIPDisabled(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -85,7 +85,7 @@ func TestDatagramGZIPDisabled(t *testing.T) { // um listener próprio. addr2, conn2 := listenUDP(t) defer conn2.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { t.Fatalf("Configure: %v", err) } _ = MchLog.Close() @@ -120,7 +120,7 @@ func TestDatagramGZIPEnabled(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -149,7 +149,7 @@ func TestLogSubjectEmptySubjectIgnored(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { diff --git a/mchlogcorev3/failure_test.go b/mchlogcorev3/failure_test.go index ebe86e0..066c41a 100644 --- a/mchlogcorev3/failure_test.go +++ b/mchlogcorev3/failure_test.go @@ -46,7 +46,7 @@ func TestSendFailureDoesNotPanic(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -70,7 +70,7 @@ func TestRateLimitedWarnOneLinePerWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -97,7 +97,7 @@ func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -147,7 +147,7 @@ func TestNotConfiguredErrorMessage(t *testing.T) { func TestInitializeBadServicePath(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize(""); err == nil { diff --git a/mchlogcorev3/file.go b/mchlogcorev3/file.go index b01954d..9d01ad2 100644 --- a/mchlogcorev3/file.go +++ b/mchlogcorev3/file.go @@ -4,7 +4,7 @@ import ( "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" ) -// fileBackend é a estratégia de arquivo do V3. Internamente delega +// fileDestination é a estratégia de arquivo do V3. Internamente delega // para mchlogcorev2.MchLog, preservando integralmente o layout // (///.log) e a JSON shape do V2. // @@ -13,25 +13,25 @@ import ( // que serviços migrem de V2 para V3 sem alterar a operação. Quando // V1/V2 forem removidos no futuro, a lógica do V2 pode ser inlineada // aqui sem mudar a API pública do V3. -type fileBackend struct { +type fileDestination struct { inner *mchlogcorev2.LogType } -// newFileBackend inicializa o V2 subjacente com o path recebido e +// newFileDestination inicializa o V2 subjacente com o path recebido e // devolve um wrapper pronto para uso. -func newFileBackend(path string) *fileBackend { +func newFileDestination(path string) *fileDestination { mchlogcorev2.InitializeMchLog(path) - return &fileBackend{inner: &mchlogcorev2.MchLog} + return &fileDestination{inner: &mchlogcorev2.MchLog} } -func (f *fileBackend) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { +func (f *fileDestination) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { if f == nil || f.inner == nil { return } f.inner.LogSubject(subject, content, errLog, ascendStackFrame...) } -func (f *fileBackend) GetFileNameFromStreamName(subject string) string { +func (f *fileDestination) GetFileNameFromStreamName(subject string) string { if f == nil || f.inner == nil { return "" } @@ -42,6 +42,6 @@ func (f *fileBackend) GetFileNameFromStreamName(subject string) string { // prévia de não alterar V1/V2). O sistema operacional libera os FDs // no encerramento do processo, comportamento idêntico ao uso direto // de mchlogcorev2. -func (f *fileBackend) Close() error { +func (f *fileDestination) Close() error { return nil } diff --git a/mchlogcorev3/file_test.go b/mchlogcorev3/file_test.go index 74f4174..119e3ac 100644 --- a/mchlogcorev3/file_test.go +++ b/mchlogcorev3/file_test.go @@ -15,7 +15,7 @@ func TestProtocolFileWritesV2LayoutAndShape(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "payments-api") + string(filepath.Separator) @@ -69,7 +69,7 @@ func TestProtocolFileErrorPrefixesSubject(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) @@ -93,7 +93,7 @@ func TestProtocolFileGetFileNameFromStreamName(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) @@ -115,7 +115,7 @@ func TestProtocolFileCloseIsNoOp(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(BackendConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) diff --git a/mchlogcorev3/gelf.go b/mchlogcorev3/gelf.go index 4192ce2..aefb0ce 100644 --- a/mchlogcorev3/gelf.go +++ b/mchlogcorev3/gelf.go @@ -63,7 +63,7 @@ func levelToSyslog(level string) int32 { // - _trace = chave "trace" // - demais chaves = prefixadas com "_" (a menos que reservadas) // - _error = errLog.Error() quando errLog != nil -func buildGELFMessage(serviceName, level string, content any, errLog error, cfg BackendConfig) (*gelf.Message, error) { +func buildGELFMessage(serviceName, level string, content any, errLog error, cfg DestinationConfig) (*gelf.Message, error) { fields, err := contentToMap(content) if err != nil { return nil, err diff --git a/mchlogcorev3/gelf_test.go b/mchlogcorev3/gelf_test.go index 5c797a9..cd618ab 100644 --- a/mchlogcorev3/gelf_test.go +++ b/mchlogcorev3/gelf_test.go @@ -41,7 +41,7 @@ func TestLevelToSyslogUnknownDefaultsToInfo(t *testing.T) { // que reproduz a saída do formatLog do logger.go. func TestBuildGELFMessageRequiredFields(t *testing.T) { payload := []byte(`{"message":"hello","level":"info","source":"foo.go","line":"42","trace":""}`) - msg, err := buildGELFMessage("payments-api", "info", payload, nil, BackendConfig{ + msg, err := buildGELFMessage("payments-api", "info", payload, nil, DestinationConfig{ Source: "pod-1", }) if err != nil { @@ -68,7 +68,7 @@ func TestBuildGELFMessageRequiredFields(t *testing.T) { // _application_name, _log_id, _level_name, _file e _line. func TestBuildGELFMessageCustomFields(t *testing.T) { payload := []byte(`{"message":"hi","level":"debug","source":"internal/foo.go","line":"99","trace":"abc"}`) - msg, err := buildGELFMessage("payments-api", "debug", payload, nil, BackendConfig{ + msg, err := buildGELFMessage("payments-api", "debug", payload, nil, DestinationConfig{ Source: "pod-1", }) if err != nil { @@ -99,7 +99,7 @@ func TestBuildGELFMessageCustomFields(t *testing.T) { // _error e mantém os demais campos. func TestBuildGELFMessageWithError(t *testing.T) { payload := []byte(`{"message":"boom","level":"error","source":"x.go","line":"7","trace":""}`) - msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), BackendConfig{ + msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), DestinationConfig{ Source: "pod-1", }) if err != nil { @@ -124,7 +124,7 @@ func TestBuildGELFMessageAcceptsMap(t *testing.T) { "message": "MchLogToolkit initialized", "version": "V3", } - msg, err := buildGELFMessage("svc", "info", content, nil, BackendConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", content, nil, DestinationConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -140,7 +140,7 @@ func TestBuildGELFMessageAcceptsMap(t *testing.T) { // "message" usa string vazia em Short e não falha. func TestBuildGELFMessageMissingMessage(t *testing.T) { payload := []byte(`{"level":"info"}`) - msg, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, DestinationConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -153,7 +153,7 @@ func TestBuildGELFMessageMissingMessage(t *testing.T) { // malformado produz erro em vez de panic. func TestBuildGELFMessageInvalidJSONReturnsError(t *testing.T) { payload := []byte(`{not json`) - if _, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}); err == nil { + if _, err := buildGELFMessage("svc", "info", payload, nil, DestinationConfig{Source: "pod-1"}); err == nil { t.Fatalf("expected error on invalid JSON") } } @@ -162,7 +162,7 @@ func TestBuildGELFMessageInvalidJSONReturnsError(t *testing.T) { // serializa em JSON válido com Extra inline (formato exigido pelo GELF). func TestBuildGELFMessageSerializable(t *testing.T) { payload := []byte(`{"message":"x","level":"info","source":"a.go","line":"1","trace":""}`) - msg, err := buildGELFMessage("svc", "info", payload, nil, BackendConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, DestinationConfig{Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } diff --git a/mchlogcorev3/integration_test.go b/mchlogcorev3/integration_test.go index c80ebb7..2dbf185 100644 --- a/mchlogcorev3/integration_test.go +++ b/mchlogcorev3/integration_test.go @@ -23,7 +23,7 @@ func TestIntegrationSendsToRealGraylog(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(BackendConfig{ + if err := Configure(DestinationConfig{ Protocol: ProtocolGraylogUDP, Addr: addr, Source: "mchlog-integration-test", diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go index 3222339..054f304 100644 --- a/mchlogcorev3/mchlogv3.go +++ b/mchlogcorev3/mchlogv3.go @@ -17,10 +17,10 @@ import ( // por janela. const warnWindow = 60 * time.Second -// backend é a estratégia interna do V3: implementações concretas -// (graylogUDP, fileBackend) atendem este contrato e são selecionadas +// destination é a estratégia interna do V3: implementações concretas +// (graylogUDP, fileDestination) atendem este contrato e são selecionadas // por Protocol em Initialize. -type backend interface { +type destination interface { LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) GetFileNameFromStreamName(subject string) string Close() error @@ -31,7 +31,7 @@ type backend interface { // Satisfaz mchlogcore.Transport e mchlogcore.Closer. type LogType struct { mu sync.RWMutex - impl backend + impl destination } // MchLog é a instância global do V3. É populada por Initialize. @@ -92,10 +92,10 @@ func Initialize(path string) error { cfg := ActiveConfig() - var impl backend + var impl destination switch cfg.Protocol { case ProtocolFile: - impl = newFileBackend(path) + impl = newFileDestination(path) case ProtocolGraylogUDP: w, err := gelf.NewWriter(cfg.Addr) if err != nil { @@ -124,7 +124,7 @@ func Initialize(path string) error { // graylogUDP envia logs em formato GELF via UDP. type graylogUDP struct { writer *gelf.Writer - cfg BackendConfig + cfg DestinationConfig serviceName string mu sync.Mutex diff --git a/mchlogcorev3/mchlogv3_test.go b/mchlogcorev3/mchlogv3_test.go index 08e1c80..714655d 100644 --- a/mchlogcorev3/mchlogv3_test.go +++ b/mchlogcorev3/mchlogv3_test.go @@ -57,7 +57,7 @@ func TestGraylogUDPSendsValidGELF(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{ + if err := Configure(DestinationConfig{ Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1", @@ -107,7 +107,7 @@ func TestGraylogUDPGetFileNameFromStreamName(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/payments-api/"); err != nil { @@ -130,7 +130,7 @@ func TestGraylogUDPCloseIdempotent(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(BackendConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { From 900f805b6ca2446e92ad12182848803eb5f24d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Tue, 5 May 2026 14:19:11 -0300 Subject: [PATCH 16/18] fix(mchlogcorev3): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugs three concrete issues raised in review and tightens defensive guards in two places: * Initialize is now safe to call repeatedly. The previous active destination is closed (outside the write lock) before the new one is installed, preventing the GELF UDP socket from leaking on re-initialization. * serviceFromPath rejects degenerate paths ("", ".", "..", "C:") so Initialize fails explicitly instead of producing nonsensical service names like "_log_id=.-mchlog-info". Backslashes are now normalized manually because filepath.ToSlash is a no-op on Unix. * contentToMap copies map[string]any into a fresh map on entry so the builder cannot accidentally mutate (or be mutated by) the caller's map. * graylogUDP.LogSubject now documents that ascendStackFrame is a no-op for this destination — _file/_line are taken from the payload (where logger.go already populated them via runtime.Caller). * fileDestination.Close godoc clarifies that no flush happens; the file backend never buffers writes. * Adds compile-time assertions confirming *mchlogcorev3.LogType satisfies both Transport and Closer. * Drops the redundant time.Now().UTC() call in buildGELFMessage: UnixNano is monotonic regardless of timezone. Tests cover the new path-normalization cases and the re-entry close. No public API changes. --- mchlogcore/mchlog.go | 4 +++- mchlogcorev3/coverage_test.go | 8 ++++++- mchlogcorev3/file.go | 4 ++++ mchlogcorev3/gelf.go | 10 +++++++-- mchlogcorev3/mchlogv3.go | 38 ++++++++++++++++++++++++++++++---- mchlogcorev3/mchlogv3_test.go | 39 +++++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index 1e15429..c75366f 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -10,10 +10,12 @@ import ( ) // Asserções de tempo de compilação garantindo que cada backend -// satisfaz a interface Transport. +// satisfaz a interface Transport (e Closer, quando aplicável). var ( _ Transport = (*mchlogcorev1.LogType)(nil) _ Transport = (*mchlogcorev2.LogType)(nil) + _ Transport = (*mchlogcorev3.LogType)(nil) + _ Closer = (*mchlogcorev3.LogType)(nil) ) // LogVersion identifica qual backend de log está em uso. diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go index 89bf949..1a0d1e6 100644 --- a/mchlogcorev3/coverage_test.go +++ b/mchlogcorev3/coverage_test.go @@ -217,7 +217,8 @@ func TestContentToMapNil(t *testing.T) { } } -// TestServiceFromPathVariants cobre formatos comuns de path. +// TestServiceFromPathVariants cobre formatos comuns de path e +// rejeita paths degenerados ("./", ".", "..", roots Windows). func TestServiceFromPathVariants(t *testing.T) { cases := map[string]string{ "/applog/payments-api/": "payments-api", @@ -225,6 +226,11 @@ func TestServiceFromPathVariants(t *testing.T) { "./applog/svc/": "svc", "": "", "/": "", + "./": "", + ".": "", + "..": "", + "C:/": "", + "C:\\": "", } for in, want := range cases { if got := serviceFromPath(in); got != want { diff --git a/mchlogcorev3/file.go b/mchlogcorev3/file.go index 9d01ad2..1b3c775 100644 --- a/mchlogcorev3/file.go +++ b/mchlogcorev3/file.go @@ -42,6 +42,10 @@ func (f *fileDestination) GetFileNameFromStreamName(subject string) string { // prévia de não alterar V1/V2). O sistema operacional libera os FDs // no encerramento do processo, comportamento idêntico ao uso direto // de mchlogcorev2. +// +// Atenção: este backend não bufferiza writes (zerolog escreve direto +// no arquivo), então não há flush a fazer aqui. Callers que esperem +// Close liberar recursos não verão diferença observável. func (f *fileDestination) Close() error { return nil } diff --git a/mchlogcorev3/gelf.go b/mchlogcorev3/gelf.go index aefb0ce..8581457 100644 --- a/mchlogcorev3/gelf.go +++ b/mchlogcorev3/gelf.go @@ -72,7 +72,7 @@ func buildGELFMessage(serviceName, level string, content any, errLog error, cfg msg := &gelf.Message{ Version: "1.1", Host: cfg.Source, - TimeUnix: float64(time.Now().UTC().UnixNano()) / float64(time.Second), + TimeUnix: float64(time.Now().UnixNano()) / float64(time.Second), Level: levelToSyslog(level), Extra: make(map[string]any), } @@ -130,7 +130,13 @@ func contentToMap(content any) (map[string]any, error) { switch v := content.(type) { case map[string]any: - return v, nil + // Copia para evitar aliasing: o caller não deve ver mutações + // que o builder possa fazer no map devolvido (e vice-versa). + out := make(map[string]any, len(v)) + for k, val := range v { + out[k] = val + } + return out, nil case map[string]string: out := make(map[string]any, len(v)) for k, s := range v { diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go index 054f304..c74f6ae 100644 --- a/mchlogcorev3/mchlogv3.go +++ b/mchlogcorev3/mchlogv3.go @@ -115,9 +115,16 @@ func Initialize(path string) error { return errors.New("mchlogcorev3: unsupported Protocol: " + string(cfg.Protocol)) } + // Troca o impl ativo. Se houver um impl anterior (Initialize chamado + // duas vezes), fecha-o fora do lock para liberar recursos (socket UDP + // no caso do graylogUDP) sem reter a write lock durante I/O. MchLog.mu.Lock() + old := MchLog.impl MchLog.impl = impl MchLog.mu.Unlock() + if old != nil { + _ = old.Close() + } return nil } @@ -132,7 +139,16 @@ type graylogUDP struct { lastWarn time.Time } +// LogSubject monta a mensagem GELF e envia via UDP. Falhas no envio +// são registradas via warnOnce e silenciadas para o caller. +// +// ascendStackFrame é aceito por compatibilidade com a interface +// mchlogcore.Transport, mas é ignorado neste backend: os campos +// _file/_line do GELF vêm do payload (que o logger.go popula via +// runtime.Caller no momento do log), não de uma re-captura aqui. func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { + _ = ascendStackFrame + if g == nil || subject == "" { return } @@ -146,8 +162,6 @@ func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascen if err := g.writer.WriteMessage(msg); err != nil { g.warnOnce(err) } - - _ = ascendStackFrame } func (g *graylogUDP) GetFileNameFromStreamName(subject string) string { @@ -188,10 +202,26 @@ func (g *graylogUDP) warnOnce(err error) { // serviceFromPath extrai o nome do serviço de um path no formato // "//" (ou variações com separadores Windows). +// Rejeita paths degenerados ("", ".", "..", "C:") devolvendo "" para +// que Initialize falhe explicitamente em vez de criar um service com +// nome inválido (ex.: "_log_id=.-mchlog-info"). func serviceFromPath(path string) string { - p := strings.TrimRight(filepath.ToSlash(path), "/ ") + // filepath.ToSlash é platform-aware (no-op em Unix). Para tratar + // paths Windows independente do SO em que o teste/serviço roda, + // normalizamos backslashes manualmente antes do trim. + normalized := strings.ReplaceAll(path, "\\", "/") + p := strings.TrimRight(normalized, "/ ") if p == "" { return "" } - return filepath.Base(p) + base := filepath.Base(p) + switch base { + case ".", "..", "/": + return "" + } + // Windows root como "C:" também não é nome de serviço válido. + if len(base) == 2 && base[1] == ':' { + return "" + } + return base } diff --git a/mchlogcorev3/mchlogv3_test.go b/mchlogcorev3/mchlogv3_test.go index 714655d..67566b7 100644 --- a/mchlogcorev3/mchlogv3_test.go +++ b/mchlogcorev3/mchlogv3_test.go @@ -155,3 +155,42 @@ func TestGraylogUDPInitializeRequiresConfigure(t *testing.T) { t.Fatalf("Initialize without Configure should error") } } + +// TestInitializeReentryClosesPrevious garante que chamar Initialize +// duas vezes fecha o destino anterior antes de instalar o novo +// (evita vazamento do socket UDP do graylogUDP). +func TestInitializeReentryClosesPrevious(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{ + Protocol: ProtocolGraylogUDP, + Addr: addr, + Source: "pod-1", + }); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc-a/"); err != nil { + t.Fatalf("first Initialize: %v", err) + } + + first, ok := MchLog.impl.(*graylogUDP) + if !ok { + t.Fatalf("expected first impl to be *graylogUDP, got %T", MchLog.impl) + } + + if err := Initialize("/applog/svc-b/"); err != nil { + t.Fatalf("second Initialize: %v", err) + } + + first.mu.Lock() + closed := first.closed + first.mu.Unlock() + if !closed { + t.Errorf("first impl should have been Closed by second Initialize") + } + + t.Cleanup(func() { _ = MchLog.Close() }) +} From 82aa71dd3b52d50ea850c8ffeba7de148bd3fe91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Tue, 5 May 2026 14:35:06 -0300 Subject: [PATCH 17/18] docs(mchlogcorev3): align comments with destination terminology Renames "backend" -> "destino" / "destinos" across godoc and README to match the public API rename (DestinationConfig, fileDestination, internal destination interface). No code change. --- README.md | 4 ++-- mchlogcore/mchlog.go | 20 ++++++++++---------- mchlogcore/transport.go | 10 +++++----- mchlogcorev3/config.go | 10 +++++----- mchlogcorev3/file.go | 2 +- mchlogcorev3/mchlogv3.go | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9be62de..4488baf 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ if err != nil { } ``` -## V3 - Backend unificado (arquivo ou Graylog) -A V3 é o backend unificado da toolkit. O serviço escolhe entre **arquivo** (mesmo layout do V2) e **GELF UDP** (Graylog) configurando `DestinationConfig.Protocol`. A API do `Logger` não muda — serviços que ainda usam V1 (default) ou V2 seguem funcionando sem alteração. +## V3 - Destino unificado (arquivo ou Graylog) +A V3 é o destino unificado da toolkit. O serviço escolhe entre **arquivo** (mesmo layout do V2) e **GELF UDP** (Graylog) configurando `DestinationConfig.Protocol`. A API do `Logger` não muda — serviços que ainda usam V1 (default) ou V2 seguem funcionando sem alteração. A V3 é a forma recomendada daqui em diante. V1 e V2 continuam disponíveis para retrocompatibilidade enquanto serviços migram. diff --git a/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index c75366f..66db644 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -9,7 +9,7 @@ import ( "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" ) -// Asserções de tempo de compilação garantindo que cada backend +// Asserções de tempo de compilação garantindo que cada destino // satisfaz a interface Transport (e Closer, quando aplicável). var ( _ Transport = (*mchlogcorev1.LogType)(nil) @@ -18,15 +18,15 @@ var ( _ Closer = (*mchlogcorev3.LogType)(nil) ) -// LogVersion identifica qual backend de log está em uso. +// LogVersion identifica qual destino de log está em uso. type LogVersion int const ( - // V1 — backend de arquivo, formato com IP e timestamp por hora. + // V1 — destino de arquivo, formato com IP e timestamp por hora. V1 LogVersion = iota - // V2 — backend de arquivo, formato simples (um arquivo por subject). + // V2 — destino de arquivo, formato simples (um arquivo por subject). V2 - // V3 — backend unificado. Suporta arquivo (mesmo layout do V2) e + // V3 — destino unificado. Suporta arquivo (mesmo layout do V2) e // rede (GELF UDP) selecionados via mchlogcorev3.DestinationConfig.Protocol. // Outros protocolos (graylog-tcp, syslog, splunk-hec, ...) podem ser // adicionados sem bumpar o enum. @@ -93,9 +93,9 @@ func (l *LogType) GetIP() string { } // Close libera recursos do transporte ativo, quando ele implementa -// a interface Closer (somente backends que precisam de cleanup explícito, -// ex.: V3 sobre UDP). Para backends de arquivo (V1, V2) é no-op. -// Idempotência é responsabilidade do backend. +// a interface Closer (somente destinos que precisam de cleanup explícito, +// ex.: V3 sobre UDP). Para destinos de arquivo (V1, V2) é no-op. +// Idempotência é responsabilidade do destino. func (l *LogType) Close() error { if c, ok := current.(Closer); ok { return c.Close() @@ -106,8 +106,8 @@ func (l *LogType) Close() error { // MchLog é a instância global do facade. var MchLog LogType -// InitializeMchLog inicializa o backend selecionado com o caminho dado. -// Em todos os backends o path tem a forma "//": +// InitializeMchLog inicializa o destino selecionado com o caminho dado. +// Em todos os destinos o path tem a forma "//": // - V1, V2 e V3-ProtocolFile usam o caminho como diretório base de arquivos. // - V3-ProtocolGraylogUDP usa o último segmento apenas para extrair // o nome do serviço; o destino real é cfg.Addr. diff --git a/mchlogcore/transport.go b/mchlogcore/transport.go index 57173a4..9390443 100644 --- a/mchlogcore/transport.go +++ b/mchlogcore/transport.go @@ -1,16 +1,16 @@ package mchlogcore // Transport é a estratégia que efetivamente persiste ou envia os logs. -// Cada backend (arquivo V1, arquivo V2, rede V3, ...) implementa esta +// Cada destino (arquivo V1, arquivo V2, rede V3, ...) implementa esta // interface e é selecionado pelo facade através de SetVersion. // -// A interface é declarada aqui no pacote do facade. Os pacotes de backend +// A interface é declarada aqui no pacote do facade. Os pacotes de destino // não precisam importá-la: como Go usa interfaces estruturais, basta que // os métodos coincidam. Isso evita ciclo de import entre mchlogcore e os // pacotes mchlogcoreV*. type Transport interface { // LogSubject persiste/envia o conteúdo de log. - // Mesma assinatura usada pelos backends V1 e V2. + // Mesma assinatura usada pelos destinos V1 e V2. LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) // GetFileNameFromStreamName devolve o caminho do arquivo (V1/V2) @@ -19,10 +19,10 @@ type Transport interface { GetFileNameFromStreamName(subject string) string } -// Closer é uma interface opcional: backends que mantêm recursos +// Closer é uma interface opcional: destinos que mantêm recursos // que precisam ser liberados explicitamente (ex.: conexões de rede) // implementam Close. O facade chama via type assertion, então -// backends que não precisam (V1, V2) não são forçados a implementar. +// destinos que não precisam (V1, V2) não são forçados a implementar. type Closer interface { // Close libera recursos do transporte. // Deve ser idempotente. diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index 3237009..541b582 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -1,4 +1,4 @@ -// Package mchlogcorev3 é o backend unificado da toolkit. Suporta múltiplos +// Package mchlogcorev3 é o destino unificado da toolkit. Suporta múltiplos // protocolos selecionados por DestinationConfig.Protocol: // // - ProtocolFile: grava em arquivo no mesmo layout do mchlogcorev2 @@ -16,7 +16,7 @@ import ( "sync" ) -// Protocol identifica o backend efetivo usado para persistir/enviar logs. +// Protocol identifica o destino efetivo usado para persistir/enviar logs. type Protocol string const ( @@ -33,7 +33,7 @@ const ( // relevantes dependem de Protocol — campos de outros protocolos são // ignorados pela validação. type DestinationConfig struct { - // Protocol seleciona o backend. Default: ProtocolFile. + // Protocol seleciona o destino. Default: ProtocolFile. Protocol Protocol // Addr é o endereço do destino no formato "host:porta". @@ -57,7 +57,7 @@ var ( ) // Configure normaliza e armazena a configuração que será usada pelo -// backend. Aplica default a Protocol e valida os campos obrigatórios +// destino. Aplica default a Protocol e valida os campos obrigatórios // para o protocolo selecionado. func Configure(cfg DestinationConfig) error { if cfg.Protocol == "" { @@ -87,7 +87,7 @@ func Configure(cfg DestinationConfig) error { } // ActiveConfig retorna uma cópia da configuração ativa. Útil para -// testes e para o backend ler os parâmetros já normalizados. +// testes e para o destino ler os parâmetros já normalizados. // Antes de Configure ser chamado, devolve um DestinationConfig zero-valued. func ActiveConfig() DestinationConfig { cfgMu.RLock() diff --git a/mchlogcorev3/file.go b/mchlogcorev3/file.go index 1b3c775..3d1bc8d 100644 --- a/mchlogcorev3/file.go +++ b/mchlogcorev3/file.go @@ -43,7 +43,7 @@ func (f *fileDestination) GetFileNameFromStreamName(subject string) string { // no encerramento do processo, comportamento idêntico ao uso direto // de mchlogcorev2. // -// Atenção: este backend não bufferiza writes (zerolog escreve direto +// Atenção: este destino não bufferiza writes (zerolog escreve direto // no arquivo), então não há flush a fazer aqui. Callers que esperem // Close liberar recursos não verão diferença observável. func (f *fileDestination) Close() error { diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go index c74f6ae..6f24913 100644 --- a/mchlogcorev3/mchlogv3.go +++ b/mchlogcorev3/mchlogv3.go @@ -143,7 +143,7 @@ type graylogUDP struct { // são registradas via warnOnce e silenciadas para o caller. // // ascendStackFrame é aceito por compatibilidade com a interface -// mchlogcore.Transport, mas é ignorado neste backend: os campos +// mchlogcore.Transport, mas é ignorado neste destino: os campos // _file/_line do GELF vêm do payload (que o logger.go popula via // runtime.Caller no momento do log), não de uma re-captura aqui. func (g *graylogUDP) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { From 660c1c508507e0f0ffc3e4119bfb8f0fb80640a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Tue, 5 May 2026 15:00:04 -0300 Subject: [PATCH 18/18] test(mchlogcorev3): close coverage gaps in failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three tests prompted by the QA review: * TestInitializeDialFailureReturnsError exercises the dial-time error path in Initialize (Addr without colon → ResolveUDPAddr fails) and asserts the wrapped "dial GELF UDP" message. * TestWriterWriteMessageFailureWarns reproduces a runtime UDP write failure (closes the underlying gelf.Writer socket and sends after) so warnOnce is exercised on the post-build path, not only the build path. * TestLogTypeLogSubjectBeforeInitialize confirms that the public facade (mchlogcorev3.MchLog) is safe to use before Initialize: LogSubject is a no-op, GetFileNameFromStreamName returns "", Close returns nil, no panic. Also updates the docstring of TestRateLimitedWarnOneLinePerWindow to clarify which failure path is exercised (buildGELFMessage), and adds a comment near resetConfig explaining why the package's tests cannot use t.Parallel (shared globals: activeCfg, MchLog.impl, mchlogcorev2). V3 coverage rises from 91.6% to 93.8%. --- mchlogcorev3/config.go | 5 ++ mchlogcorev3/failure_test.go | 95 +++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index 541b582..4a0793a 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -115,6 +115,11 @@ func DefaultSource() string { // resetConfig limpa o estado de configuração. Usado apenas em testes // (não exportado). +// +// Os testes do pacote NÃO usam t.Parallel: activeCfg, MchLog.impl e o +// global de mchlogcorev2 são compartilhados, então rodar testes em +// paralelo causaria interferência. Cada teste chama +// t.Cleanup(resetConfig) para deixar o estado pronto para o próximo. func resetConfig() { cfgMu.Lock() activeCfg = DestinationConfig{} diff --git a/mchlogcorev3/failure_test.go b/mchlogcorev3/failure_test.go index 066c41a..280ac05 100644 --- a/mchlogcorev3/failure_test.go +++ b/mchlogcorev3/failure_test.go @@ -63,8 +63,11 @@ func TestSendFailureDoesNotPanic(t *testing.T) { MchLog.LogSubject("info", 123, nil) } -// TestRateLimitedWarnOneLinePerWindow garante que 100 falhas de envio -// dentro da janela produzem exatamente uma linha em stderr. +// TestRateLimitedWarnOneLinePerWindow garante que 100 falhas seguidas +// dentro da janela de warnWindow produzem exatamente uma linha em +// stderr. O caminho exercitado aqui é a falha em buildGELFMessage +// (content type não suportado); a falha em writer.WriteMessage é +// coberta separadamente por TestWriterWriteMessageFailureWarns. func TestRateLimitedWarnOneLinePerWindow(t *testing.T) { t.Cleanup(resetConfig) addr, conn := listenUDP(t) @@ -154,3 +157,91 @@ func TestInitializeBadServicePath(t *testing.T) { t.Fatalf("expected error for empty path") } } + +// TestInitializeDialFailureReturnsError cobre o caminho em que +// gelf.NewWriter falha (Addr malformado, sem porta) e Initialize +// devolve o erro embrulhado em "dial GELF UDP %s: %w". +func TestInitializeDialFailureReturnsError(t *testing.T) { + t.Cleanup(resetConfig) + + if err := Configure(DestinationConfig{ + Protocol: ProtocolGraylogUDP, + Addr: "no-port-no-colon", + Source: "pod-1", + }); err != nil { + t.Fatalf("Configure: %v", err) + } + err := Initialize("/applog/svc/") + if err == nil { + t.Fatalf("expected dial error for malformed Addr") + } + if !strings.Contains(err.Error(), "dial GELF UDP") { + t.Errorf("error should mention dial: %q", err.Error()) + } +} + +// TestWriterWriteMessageFailureWarns garante que uma falha em +// writer.WriteMessage (não em buildGELFMessage) também aciona +// warnOnce. Forçamos a falha fechando o socket subjacente do +// gelf.Writer antes de chamar LogSubject — o WriteMessage tenta +// escrever em conexão fechada e devolve erro. +func TestWriterWriteMessageFailureWarns(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + t.Fatalf("Configure: %v", err) + } + if err := Initialize("/applog/svc/"); err != nil { + t.Fatalf("Initialize: %v", err) + } + + g, ok := MchLog.impl.(*graylogUDP) + if !ok { + t.Fatalf("expected *graylogUDP, got %T", MchLog.impl) + } + + // Fecha o writer subjacente sem mexer no flag g.closed (replica a + // situação em que a conexão UDP foi perdida, mas o transporte ainda + // está ativo do ponto de vista do facade). + if err := g.writer.Close(); err != nil { + t.Fatalf("writer.Close: %v", err) + } + + got := captureStderr(t, func() { + MchLog.LogSubject("info", []byte(`{"message":"after close"}`), nil) + }) + if !strings.Contains(got, "GELF UDP send failed") { + t.Errorf("expected warn line on writer failure; stderr:\n%s", got) + } +} + +// TestLogTypeLogSubjectBeforeInitialize garante que o facade do V3 +// (mchlogcorev3.MchLog) é seguro de usar antes de Initialize: +// LogSubject vira no-op e GetFileNameFromStreamName devolve "". +func TestLogTypeLogSubjectBeforeInitialize(t *testing.T) { + t.Cleanup(resetConfig) + resetConfig() + + // Garante que MchLog.impl está nil (pode ter sido populado por + // um teste anterior se a ordem mudar). + MchLog.mu.Lock() + MchLog.impl = nil + MchLog.mu.Unlock() + + defer func() { + if r := recover(); r != nil { + t.Fatalf("LogSubject before Initialize panicked: %v", r) + } + }() + MchLog.LogSubject("info", []byte(`{"message":"x"}`), nil) // no-op + + if got := MchLog.GetFileNameFromStreamName("info"); got != "" { + t.Errorf("GetFileNameFromStreamName before Initialize = %q, want empty", got) + } + if err := MchLog.Close(); err != nil { + t.Errorf("Close before Initialize = %v, want nil", err) + } +}