From 0298278396fcb042e5609230f56a8fa506bc1b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Wed, 20 May 2026 14:25:56 -0300 Subject: [PATCH 1/3] feat(mchlogcorev3)!: route LogSubject by subject (file + optional UDP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 internally wraps every Initialize in a routerDestination that holds a file impl (always) and an optional network impl. Level-like subjects (test/debug/info/warn/error/fatal) — i.e., output from Logger.Info/.Warn/ .Error/... — go to the network impl when configured; everything else (domain events) goes to file in the same V2 layout. Caller may extend the level set via DestinationConfig.NetworkSubjects. Configure shape reshaped: Protocol/ProtocolFile/ProtocolGraylogUDP/Addr/ Source/DisableGZIP replaced by Network *NetworkConfig{Type, Addr, Source, DisableGZIP} + NetworkSubjects []string. Zero value keeps file-only behavior (same as legacy ProtocolFile). Initialize fails loud on UDP dial errors; routing is exclusive (no tee). --- mchlogcore/v3_dispatch_test.go | 8 +- mchlogcorev3/config.go | 127 +++++--- mchlogcorev3/config_test.go | 149 +++++++--- mchlogcorev3/coverage_test.go | 10 +- mchlogcorev3/failure_test.go | 33 +-- mchlogcorev3/file_test.go | 28 +- mchlogcorev3/gelf.go | 2 +- mchlogcorev3/gelf_test.go | 17 +- mchlogcorev3/integration_test.go | 10 +- mchlogcorev3/mchlogv3.go | 64 ++-- mchlogcorev3/mchlogv3_test.go | 29 +- mchlogcorev3/router.go | 111 +++++++ mchlogcorev3/router_test.go | 487 +++++++++++++++++++++++++++++++ 13 files changed, 896 insertions(+), 179 deletions(-) create mode 100644 mchlogcorev3/router.go create mode 100644 mchlogcorev3/router_test.go diff --git a/mchlogcore/v3_dispatch_test.go b/mchlogcore/v3_dispatch_test.go index b76b7cf..a6ec6f9 100644 --- a/mchlogcore/v3_dispatch_test.go +++ b/mchlogcore/v3_dispatch_test.go @@ -55,9 +55,11 @@ func TestSetVersionV3DispatchesToGraylog(t *testing.T) { defer conn.Close() if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ - Protocol: mchlogcorev3.ProtocolGraylogUDP, - Addr: addr, - Source: "pod-1", + Network: &mchlogcorev3.NetworkConfig{ + Type: mchlogcorev3.NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }, }); err != nil { t.Fatalf("Configure: %v", err) } diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index 4a0793a..b75cbcb 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -1,13 +1,23 @@ -// Package mchlogcorev3 é o destino unificado da toolkit. Suporta múltiplos -// protocolos selecionados por DestinationConfig.Protocol: +// Package mchlogcorev3 é o destino unificado da toolkit. O V3 sempre +// roteia chamadas LogSubject por um routerDestination que combina dois +// impls: file (sempre presente) e network (opcional). // -// - ProtocolFile: grava em arquivo no mesmo layout do mchlogcorev2 -// (///.log) e mesma JSON shape. -// - ProtocolGraylogUDP: envia em formato GELF via UDP para o Graylog. +// Regra de roteamento (exata, case-sensitive): // -// 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. +// - subjects "level-like" da toolkit (test, debug, info, warn, error, +// fatal) → network impl, se configurado; caso contrário, file. +// - subjects estendidos via DestinationConfig.NetworkSubjects → mesma +// regra dos level-like. +// - qualquer outro subject (eventos de domínio, ex.: historico_posicao_taxi, +// log_posicao_alterada, etc.) → file impl. +// +// O file impl usa o mesmo layout do mchlogcorev2 +// (///.log) e a mesma JSON shape. +// +// Atualmente o único tipo de network suportado é Graylog UDP (GELF). +// Novos transportes (graylog-tcp, syslog, splunk-hec, etc.) podem ser +// adicionados expondo novos valores de NetworkType e a implementação +// correspondente; a API pública (LogSubject) não muda. package mchlogcorev3 import ( @@ -16,40 +26,52 @@ import ( "sync" ) -// Protocol identifica o destino efetivo usado para persistir/enviar logs. -type Protocol string +// NetworkType identifica o transporte do impl de rede. +type NetworkType 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" + // NetworkGraylogUDP envia logs em formato GELF via UDP. + NetworkGraylogUDP NetworkType = "graylog-udp" ) -// 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 DestinationConfig struct { - // Protocol seleciona o destino. Default: ProtocolFile. - Protocol Protocol +// NetworkConfig descreve o destino de rede opcional. Quando presente em +// DestinationConfig, subjects "level-like" (e os explicitamente listados +// em NetworkSubjects) são enviados por aqui em vez de gravados em disco. +type NetworkConfig struct { + // Type seleciona o transporte. Hoje só NetworkGraylogUDP. + Type NetworkType // Addr é o endereço do destino no formato "host:porta". - // Obrigatório quando Protocol = ProtocolGraylogUDP. Addr string // 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). + // no Graylog). Fornecido pelo serviço (a toolkit não autodetecta). Source string // DisableGZIP desabilita a compressão GZIP do GELF UDP. Default - // (zero value) = GZIP habilitado. Aplica apenas a ProtocolGraylogUDP. + // (zero value) = GZIP habilitado. DisableGZIP bool } +// DestinationConfig agrupa os parâmetros aceitos pelo V3. +// +// Zero value (Network==nil, NetworkSubjects==nil) configura o V3 em modo +// "file-only": todos os subjects vão para arquivo no mesmo layout do V2. +type DestinationConfig struct { + // Network é opcional. Quando nil, todos os subjects vão para arquivo. + // Quando definido, subjects level-like (e os listados em + // NetworkSubjects) vão por aqui; o restante continua em arquivo. + Network *NetworkConfig + + // NetworkSubjects estende a whitelist default de subjects roteados + // para o network impl. A whitelist default é o conjunto fixo de + // levels da toolkit (test, debug, info, warn, error, fatal). + // Match é exato e case-sensitive. Strings vazias são ignoradas. + // + // Só faz sentido com Network != nil. Configure rejeita o contrário. + NetworkSubjects []string +} + var ( cfgMu sync.RWMutex activeCfg DestinationConfig @@ -57,26 +79,37 @@ var ( ) // Configure normaliza e armazena a configuração que será usada pelo -// destino. Aplica default a Protocol e valida os campos obrigatórios -// para o protocolo selecionado. +// destino. Valida os campos obrigatórios para o transporte de rede +// selecionado (quando presente). +// +// A NetworkConfig recebida é copiada antes do armazenamento, então o +// caller pode mutar/descartar a struct após o retorno. func Configure(cfg DestinationConfig) error { - if cfg.Protocol == "" { - cfg.Protocol = ProtocolFile + if cfg.Network != nil { + netCfg := *cfg.Network + switch netCfg.Type { + case "": + return errors.New("mchlogcorev3: Network.Type is required when Network is set") + case NetworkGraylogUDP: + if netCfg.Addr == "" { + return errors.New("mchlogcorev3: Network.Addr is required for NetworkGraylogUDP") + } + if netCfg.Source == "" { + return errors.New("mchlogcorev3: Network.Source is required for NetworkGraylogUDP (caller-provided)") + } + default: + return errors.New("mchlogcorev3: unknown Network.Type: " + string(netCfg.Type)) + } + cfg.Network = &netCfg + } else if len(cfg.NetworkSubjects) > 0 { + return errors.New("mchlogcorev3: NetworkSubjects requires Network to be set") } - 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: - 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)) + if len(cfg.NetworkSubjects) > 0 { + // Copia o slice para isolar mutações posteriores no caller. + dup := make([]string, len(cfg.NetworkSubjects)) + copy(dup, cfg.NetworkSubjects) + cfg.NetworkSubjects = dup } cfgMu.Lock() @@ -89,6 +122,10 @@ func Configure(cfg DestinationConfig) error { // ActiveConfig retorna uma cópia da configuração ativa. Útil para // testes e para o destino ler os parâmetros já normalizados. // Antes de Configure ser chamado, devolve um DestinationConfig zero-valued. +// +// Atenção: o ponteiro Network é compartilhado com a cópia interna. +// Callers que mutarem *ActiveConfig().Network corromperão o estado; +// trate-o como read-only. func ActiveConfig() DestinationConfig { cfgMu.RLock() defer cfgMu.RUnlock() @@ -105,7 +142,7 @@ 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. Útil apenas para -// ProtocolGraylogUDP. +// NetworkGraylogUDP. func DefaultSource() string { if h, err := os.Hostname(); err == nil && h != "" { return h diff --git a/mchlogcorev3/config_test.go b/mchlogcorev3/config_test.go index 86a3a00..a1885f5 100644 --- a/mchlogcorev3/config_test.go +++ b/mchlogcorev3/config_test.go @@ -13,27 +13,31 @@ func TestDefaultSource(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) { +// TestConfigureZeroValueIsFileOnly garante que sem Network configurado +// o V3 entra em modo file-only (caminho de menor surpresa para callers +// migrando de V2). +func TestConfigureZeroValueIsFileOnly(t *testing.T) { t.Cleanup(resetConfig) if err := Configure(DestinationConfig{}); err != nil { - t.Fatalf("Configure with empty config should be valid for file: %v", err) + t.Fatalf("Configure with empty config should be valid for file-only: %v", err) } - if got := ActiveConfig().Protocol; got != ProtocolFile { - t.Errorf("default Protocol = %q, want %q", got, ProtocolFile) + if got := ActiveConfig().Network; got != nil { + t.Errorf("expected Network==nil, got %+v", got) } } -// TestConfigureFileNoRequiredFields garante que ProtocolFile não exige -// Addr nem Source (essas são exclusivas do GraylogUDP). -func TestConfigureFileNoRequiredFields(t *testing.T) { +// TestConfigureNetworkRequiresType garante que NetworkConfig sem Type +// é rejeitado. +func TestConfigureNetworkRequiresType(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { - t.Fatalf("Configure should accept file with no fields: %v", err) + err := Configure(DestinationConfig{Network: &NetworkConfig{ + Addr: "graylog.dev:12201", + Source: "svc-x", + }}) + if err == nil { + t.Fatalf("Configure should reject empty Network.Type") } } @@ -41,9 +45,12 @@ func TestConfigureFileNoRequiredFields(t *testing.T) { func TestConfigureGraylogUDPRequiresAddr(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Source: "svc-x"}) + err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Source: "svc-x", + }}) if err == nil { - t.Fatalf("Configure should reject empty Addr for ProtocolGraylogUDP") + t.Fatalf("Configure should reject empty Addr for NetworkGraylogUDP") } } @@ -51,9 +58,12 @@ func TestConfigureGraylogUDPRequiresAddr(t *testing.T) { func TestConfigureGraylogUDPRequiresSource(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: "graylog.dev:12201"}) + err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: "graylog.dev:12201", + }}) if err == nil { - t.Fatalf("Configure should reject empty Source for ProtocolGraylogUDP") + t.Fatalf("Configure should reject empty Source for NetworkGraylogUDP") } } @@ -61,22 +71,25 @@ func TestConfigureGraylogUDPRequiresSource(t *testing.T) { func TestConfigureGraylogUDPHappy(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, - Addr: "graylog.dev:12201", - Source: "svc-x", - }); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: "graylog.dev:12201", + Source: "svc-x", + }}); err != nil { t.Fatalf("Configure failed: %v", err) } got := ActiveConfig() - if got.Protocol != ProtocolGraylogUDP { - t.Errorf("Protocol = %q", got.Protocol) + if got.Network == nil { + t.Fatalf("expected Network!=nil") + } + if got.Network.Type != NetworkGraylogUDP { + t.Errorf("Type = %q", got.Network.Type) } - if got.DisableGZIP { + if got.Network.DisableGZIP { t.Errorf("GZIP must be enabled by default (DisableGZIP=false)") } - if got.Addr != "graylog.dev:12201" { - t.Errorf("Addr = %q", got.Addr) + if got.Network.Addr != "graylog.dev:12201" { + t.Errorf("Addr = %q", got.Network.Addr) } } @@ -85,27 +98,86 @@ func TestConfigureGraylogUDPHappy(t *testing.T) { func TestConfigureDisableGZIPRespected(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, Addr: "graylog.dev:12201", Source: "svc-x", DisableGZIP: true, - }); err != nil { + }}); err != nil { t.Fatalf("Configure failed: %v", err) } - if !ActiveConfig().DisableGZIP { + if !ActiveConfig().Network.DisableGZIP { t.Errorf("DisableGZIP=true was overwritten") } } -// TestConfigureRejectsUnknownProtocol garante futuro-proofing para -// quando outros protocolos forem adicionados. -func TestConfigureRejectsUnknownProtocol(t *testing.T) { +// TestConfigureRejectsUnknownNetworkType garante futuro-proofing para +// quando outros transportes forem adicionados. +func TestConfigureRejectsUnknownNetworkType(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: "graylog-tcp", + Addr: "graylog.dev:12201", + Source: "svc-x", + }}) + if err == nil { + t.Fatalf("Configure should reject unknown Network.Type") + } +} + +// TestConfigureCopiesNetworkConfig garante que mutações do caller após +// Configure não afetam o estado armazenado. +func TestConfigureCopiesNetworkConfig(t *testing.T) { + t.Cleanup(resetConfig) + + cfg := &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: "graylog.dev:12201", + Source: "svc-x", + } + if err := Configure(DestinationConfig{Network: cfg}); err != nil { + t.Fatalf("Configure: %v", err) + } + cfg.Addr = "evil.dev:1" + + if got := ActiveConfig().Network.Addr; got != "graylog.dev:12201" { + t.Errorf("internal config mutated by caller, Addr=%q", got) + } +} + +// TestConfigureNetworkSubjectsRequireNetwork garante que listar subjects +// extras sem destino de rede é erro claro. +func TestConfigureNetworkSubjectsRequireNetwork(t *testing.T) { t.Cleanup(resetConfig) - err := Configure(DestinationConfig{Protocol: "graylog-tcp"}) + err := Configure(DestinationConfig{NetworkSubjects: []string{"foo"}}) if err == nil { - t.Fatalf("Configure should reject unknown protocol") + t.Fatalf("Configure should reject NetworkSubjects without Network") + } +} + +// TestConfigureNetworkSubjectsCopied garante que mutações no slice +// passado pelo caller não afetam o estado armazenado. +func TestConfigureNetworkSubjectsCopied(t *testing.T) { + t.Cleanup(resetConfig) + + subs := []string{"historico_posicao_taxi"} + if err := Configure(DestinationConfig{ + Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: "graylog.dev:12201", + Source: "svc-x", + }, + NetworkSubjects: subs, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + subs[0] = "tampered" + + got := ActiveConfig().NetworkSubjects + if len(got) != 1 || got[0] != "historico_posicao_taxi" { + t.Errorf("internal NetworkSubjects mutated by caller: %v", got) } } @@ -116,8 +188,11 @@ func TestActiveConfigBeforeConfigure(t *testing.T) { resetConfig() got := ActiveConfig() - if got.Addr != "" || got.Source != "" { - t.Errorf("ActiveConfig before Configure should be zero-valued, got %+v", got) + if got.Network != nil { + t.Errorf("ActiveConfig before Configure should be zero-valued, got Network=%+v", got.Network) + } + if len(got.NetworkSubjects) != 0 { + t.Errorf("ActiveConfig before Configure should be zero-valued, got NetworkSubjects=%v", got.NetworkSubjects) } if IsConfigured() { t.Errorf("IsConfigured should be false before Configure") diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go index 1a0d1e6..956eb27 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr2, Source: "pod-1", DisableGZIP: true}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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 280ac05..3066346 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, Addr: addr, Source: "pod-1"}}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -73,7 +73,7 @@ func TestRateLimitedWarnOneLinePerWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, Addr: addr, Source: "pod-1"}}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -100,7 +100,7 @@ func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, Addr: addr, Source: "pod-1"}}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -112,13 +112,7 @@ func TestRateLimitedWarnEmitsAgainAfterWindow(t *testing.T) { MchLog.LogSubject("info", 123, nil) // 1ª falha → warn // 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 := currentGraylogUDP(t) g.mu.Lock() g.lastWarn = time.Time{} g.mu.Unlock() @@ -150,7 +144,7 @@ func TestNotConfiguredErrorMessage(t *testing.T) { func TestInitializeBadServicePath(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: "127.0.0.1:1", Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, Addr: "127.0.0.1:1", Source: "pod-1"}}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize(""); err == nil { @@ -164,11 +158,11 @@ func TestInitializeBadServicePath(t *testing.T) { func TestInitializeDialFailureReturnsError(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, - Addr: "no-port-no-colon", - Source: "pod-1", - }); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: "no-port-no-colon", + Source: "pod-1", + }}); err != nil { t.Fatalf("Configure: %v", err) } err := Initialize("/applog/svc/") @@ -191,17 +185,14 @@ func TestWriterWriteMessageFailureWarns(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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) - } + g := currentGraylogUDP(t) // 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 diff --git a/mchlogcorev3/file_test.go b/mchlogcorev3/file_test.go index 119e3ac..a0ae8db 100644 --- a/mchlogcorev3/file_test.go +++ b/mchlogcorev3/file_test.go @@ -8,14 +8,14 @@ import ( "testing" ) -// TestProtocolFileWritesV2LayoutAndShape garante que com ProtocolFile o -// V3 grava o log no caminho ///.log +// TestFileOnlyWritesV2LayoutAndShape garante que sem Network configurado +// 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) { +func TestFileOnlyWritesV2LayoutAndShape(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "payments-api") + string(filepath.Separator) @@ -63,13 +63,13 @@ func TestProtocolFileWritesV2LayoutAndShape(t *testing.T) { } } -// TestProtocolFileErrorPrefixesSubject garante que erros vão para +// TestFileErrorPrefixesSubject garante que erros vão para // pasta err_/, mantendo o comportamento do V2. -func TestProtocolFileErrorPrefixesSubject(t *testing.T) { +func TestFileErrorPrefixesSubject(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) @@ -87,13 +87,13 @@ func TestProtocolFileErrorPrefixesSubject(t *testing.T) { } } -// TestProtocolFileGetFileNameFromStreamName devolve caminho real de -// arquivo (delegando ao V2). -func TestProtocolFileGetFileNameFromStreamName(t *testing.T) { +// TestFileOnlyGetFileNameFromStreamName devolve caminho real de +// arquivo (delegando ao V2) quando não há network configurado. +func TestFileOnlyGetFileNameFromStreamName(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{}); err != nil { t.Fatalf("Configure: %v", err) } servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) @@ -109,13 +109,13 @@ func TestProtocolFileGetFileNameFromStreamName(t *testing.T) { } } -// TestProtocolFileCloseIsNoOp documenta que Close é no-op para V3-file +// TestFileCloseIsNoOp documenta que Close é no-op para o file impl // (V2 subjacente não expõe Close — decisão prévia). Idempotente. -func TestProtocolFileCloseIsNoOp(t *testing.T) { +func TestFileCloseIsNoOp(t *testing.T) { t.Cleanup(resetConfig) dir := t.TempDir() - if err := Configure(DestinationConfig{Protocol: ProtocolFile}); err != nil { + if err := Configure(DestinationConfig{}); 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 8581457..74ddb0f 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 DestinationConfig) (*gelf.Message, error) { +func buildGELFMessage(serviceName, level string, content any, errLog error, cfg NetworkConfig) (*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 cd618ab..f53b954 100644 --- a/mchlogcorev3/gelf_test.go +++ b/mchlogcorev3/gelf_test.go @@ -41,7 +41,8 @@ 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, DestinationConfig{ + msg, err := buildGELFMessage("payments-api", "info", payload, nil, NetworkConfig{ + Type: NetworkGraylogUDP, Source: "pod-1", }) if err != nil { @@ -68,7 +69,8 @@ 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, DestinationConfig{ + msg, err := buildGELFMessage("payments-api", "debug", payload, nil, NetworkConfig{ + Type: NetworkGraylogUDP, Source: "pod-1", }) if err != nil { @@ -99,7 +101,8 @@ 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"), DestinationConfig{ + msg, err := buildGELFMessage("svc", "error", payload, errors.New("kaboom"), NetworkConfig{ + Type: NetworkGraylogUDP, Source: "pod-1", }) if err != nil { @@ -124,7 +127,7 @@ func TestBuildGELFMessageAcceptsMap(t *testing.T) { "message": "MchLogToolkit initialized", "version": "V3", } - msg, err := buildGELFMessage("svc", "info", content, nil, DestinationConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", content, nil, NetworkConfig{Type: NetworkGraylogUDP, Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -140,7 +143,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, DestinationConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Type: NetworkGraylogUDP, Source: "pod-1"}) if err != nil { t.Fatalf("build failed: %v", err) } @@ -153,7 +156,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, DestinationConfig{Source: "pod-1"}); err == nil { + if _, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Type: NetworkGraylogUDP, Source: "pod-1"}); err == nil { t.Fatalf("expected error on invalid JSON") } } @@ -162,7 +165,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, DestinationConfig{Source: "pod-1"}) + msg, err := buildGELFMessage("svc", "info", payload, nil, NetworkConfig{Type: NetworkGraylogUDP, 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 2dbf185..2223000 100644 --- a/mchlogcorev3/integration_test.go +++ b/mchlogcorev3/integration_test.go @@ -23,11 +23,11 @@ func TestIntegrationSendsToRealGraylog(t *testing.T) { t.Cleanup(resetConfig) - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, - Addr: addr, - Source: "mchlog-integration-test", - }); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "mchlog-integration-test", + }}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/mchlog-test/"); err != nil { diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go index 6f24913..ad12e58 100644 --- a/mchlogcorev3/mchlogv3.go +++ b/mchlogcorev3/mchlogv3.go @@ -18,8 +18,8 @@ import ( const warnWindow = 60 * time.Second // destination é a estratégia interna do V3: implementações concretas -// (graylogUDP, fileDestination) atendem este contrato e são selecionadas -// por Protocol em Initialize. +// (graylogUDP, fileDestination, routerDestination) atendem este contrato +// e são selecionadas em Initialize. type destination interface { LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) GetFileNameFromStreamName(subject string) string @@ -27,8 +27,8 @@ type destination interface { } // 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. +// (file, rede, ou roteador) escolhida em Initialize e delega todas as +// chamadas. Satisfaz mchlogcore.Transport e mchlogcore.Closer. type LogType struct { mu sync.RWMutex impl destination @@ -92,29 +92,24 @@ func Initialize(path string) error { cfg := ActiveConfig() - var impl destination - switch cfg.Protocol { - case ProtocolFile: - impl = newFileDestination(path) - case ProtocolGraylogUDP: - w, err := gelf.NewWriter(cfg.Addr) - if err != nil { - return fmt.Errorf("mchlogcorev3: dial GELF UDP %s: %w", cfg.Addr, err) + file := newFileDestination(path) + + var network destination + if cfg.Network != nil { + switch cfg.Network.Type { + case NetworkGraylogUDP: + netImpl, err := newGraylogUDP(*cfg.Network, service) + if err != nil { + return err + } + network = netImpl + default: + return errors.New("mchlogcorev3: unsupported Network.Type: " + string(cfg.Network.Type)) } - 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)) } + impl := newRouterDestination(file, network, cfg.NetworkSubjects) + // 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. @@ -128,10 +123,29 @@ func Initialize(path string) error { return nil } +// newGraylogUDP abre o writer GELF UDP e devolve o impl pronto para uso. +// Falha de dial é propagada para o caller (Initialize). +func newGraylogUDP(cfg NetworkConfig, service string) (*graylogUDP, error) { + w, err := gelf.NewWriter(cfg.Addr) + if err != nil { + return nil, fmt.Errorf("mchlogcorev3: dial GELF UDP %s: %w", cfg.Addr, err) + } + if cfg.DisableGZIP { + w.CompressionType = gelf.CompressNone + } else { + w.CompressionType = gelf.CompressGzip + } + return &graylogUDP{ + writer: w, + cfg: cfg, + serviceName: service, + }, nil +} + // graylogUDP envia logs em formato GELF via UDP. type graylogUDP struct { writer *gelf.Writer - cfg DestinationConfig + cfg NetworkConfig serviceName string mu sync.Mutex diff --git a/mchlogcorev3/mchlogv3_test.go b/mchlogcorev3/mchlogv3_test.go index 67566b7..40d4042 100644 --- a/mchlogcorev3/mchlogv3_test.go +++ b/mchlogcorev3/mchlogv3_test.go @@ -57,11 +57,11 @@ func TestGraylogUDPSendsValidGELF(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, - Addr: addr, - Source: "pod-1", - }); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/payments-api/"); err != nil { @@ -107,7 +107,7 @@ func TestGraylogUDPGetFileNameFromStreamName(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, Addr: addr, Source: "pod-1"}); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{Type: NetworkGraylogUDP, Addr: addr, Source: "pod-1"}}); err != nil { t.Fatalf("Configure: %v", err) } if err := Initialize("/applog/svc/"); err != nil { @@ -165,21 +165,18 @@ func TestInitializeReentryClosesPrevious(t *testing.T) { addr, conn := listenUDP(t) defer conn.Close() - if err := Configure(DestinationConfig{ - Protocol: ProtocolGraylogUDP, - Addr: addr, - Source: "pod-1", - }); err != nil { + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + 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) - } + first := currentGraylogUDP(t) if err := Initialize("/applog/svc-b/"); err != nil { t.Fatalf("second Initialize: %v", err) diff --git a/mchlogcorev3/router.go b/mchlogcorev3/router.go new file mode 100644 index 0000000..5fdd90d --- /dev/null +++ b/mchlogcorev3/router.go @@ -0,0 +1,111 @@ +package mchlogcorev3 + +// defaultLevelSubjects são os subjects "level-like" emitidos pelo Logger +// da toolkit (Test/Debug/Info/Warn/Error/Fatal). São sempre roteados +// para o network impl quando este está configurado. Subjects fora desta +// lista (eventos de domínio) vão para o file impl. +// +// Para estender a whitelist, use DestinationConfig.NetworkSubjects. +var defaultLevelSubjects = []string{ + "test", "debug", "info", "warn", "error", "fatal", +} + +// routerDestination roteia chamadas LogSubject por subject: +// +// - subjects em whitelist → network impl (se configurado) +// - qualquer outro subject → file impl +// +// File impl é sempre presente; network impl é opcional. Quando network +// é nil, todos os subjects vão para file — comportamento idêntico ao +// V2. +// +// A whitelist é construída em newRouterDestination e é read-only após +// isso, então lookups não precisam de lock. +type routerDestination struct { + file destination + network destination + whitelist map[string]struct{} +} + +// newRouterDestination compõe a whitelist (defaults ∪ extra) e devolve +// o router. file deve ser não-nil; network pode ser nil. +func newRouterDestination(file, network destination, extra []string) *routerDestination { + wl := make(map[string]struct{}, len(defaultLevelSubjects)+len(extra)) + for _, s := range defaultLevelSubjects { + wl[s] = struct{}{} + } + for _, s := range extra { + if s == "" { + continue + } + wl[s] = struct{}{} + } + return &routerDestination{ + file: file, + network: network, + whitelist: wl, + } +} + +// shouldNetwork devolve true quando o subject deve ser enviado pelo +// network impl. Falso quando network==nil ou subject está fora da +// whitelist. +func (r *routerDestination) shouldNetwork(subject string) bool { + if r == nil || r.network == nil { + return false + } + _, ok := r.whitelist[subject] + return ok +} + +// LogSubject roteia para network ou file conforme a whitelist. Subject +// vazio é no-op (preserva guard prévio do graylogUDP e evita criar +// arquivos com nome vazio no fileDestination). +func (r *routerDestination) LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) { + if r == nil || subject == "" { + return + } + if r.shouldNetwork(subject) { + r.network.LogSubject(subject, content, errLog, ascendStackFrame...) + return + } + r.file.LogSubject(subject, content, errLog, ascendStackFrame...) +} + +// GetFileNameFromStreamName devolve o descritor do impl que receberia +// um LogSubject para o subject informado. Para subjects roteados ao +// network, é "udp:///" (devolvido pelo graylogUDP). +// Para os demais, é o caminho real do arquivo no disco (devolvido pelo +// fileDestination / V2). +func (r *routerDestination) GetFileNameFromStreamName(subject string) string { + if r == nil || subject == "" { + return "" + } + if r.shouldNetwork(subject) { + return r.network.GetFileNameFromStreamName(subject) + } + return r.file.GetFileNameFromStreamName(subject) +} + +// Close fecha ambos os impls. Retorna o primeiro erro encontrado, mas +// continua fechando o outro impl mesmo se um deles falhar. Idempotente +// na prática: graylogUDP.Close e fileDestination.Close já são idempotentes, +// então chamadas repetidas via LogType.Close (que zera MchLog.impl) ou +// re-entrada não disparam erros. +func (r *routerDestination) Close() error { + if r == nil { + return nil + } + var firstErr error + if r.network != nil { + if err := r.network.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if r.file != nil { + if err := r.file.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/mchlogcorev3/router_test.go b/mchlogcorev3/router_test.go new file mode 100644 index 0000000..b658f86 --- /dev/null +++ b/mchlogcorev3/router_test.go @@ -0,0 +1,487 @@ +package mchlogcorev3 + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// currentGraylogUDP devolve o *graylogUDP atualmente embrulhado pelo +// routerDestination ativo. Falha o teste com t.Fatalf se MchLog não +// estiver inicializado, se o impl não for um router, ou se o router +// não tiver um network impl do tipo *graylogUDP. +// +// Helper compartilhado por failure_test.go e mchlogv3_test.go, que +// antes acessavam MchLog.impl.(*graylogUDP) diretamente. Como o V3 +// agora sempre embrulha em router, esse atalho não funciona mais. +func currentGraylogUDP(t *testing.T) *graylogUDP { + t.Helper() + MchLog.mu.RLock() + impl := MchLog.impl + MchLog.mu.RUnlock() + r, ok := impl.(*routerDestination) + if !ok { + t.Fatalf("expected *routerDestination, got %T", impl) + } + g, ok := r.network.(*graylogUDP) + if !ok { + t.Fatalf("expected router.network to be *graylogUDP, got %T", r.network) + } + return g +} + +// recordingDestination é um destination de teste que apenas anota a +// chamada e devolve respostas pré-programadas. +type recordingDestination struct { + name string + + mu sync.Mutex + logCalls []string // subjects recebidos + getCalls []string + closeCalls int + closeErr error + descriptor string // valor devolvido por GetFileNameFromStreamName +} + +func newRecorder(name, descriptor string) *recordingDestination { + return &recordingDestination{name: name, descriptor: descriptor} +} + +func (d *recordingDestination) LogSubject(subject string, _ any, _ error, _ ...int) { + d.mu.Lock() + defer d.mu.Unlock() + d.logCalls = append(d.logCalls, subject) +} + +func (d *recordingDestination) GetFileNameFromStreamName(subject string) string { + d.mu.Lock() + defer d.mu.Unlock() + d.getCalls = append(d.getCalls, subject) + return d.descriptor + ":" + subject +} + +func (d *recordingDestination) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + d.closeCalls++ + return d.closeErr +} + +func (d *recordingDestination) logged() []string { + d.mu.Lock() + defer d.mu.Unlock() + out := make([]string, len(d.logCalls)) + copy(out, d.logCalls) + return out +} + +func (d *recordingDestination) reset() { + d.mu.Lock() + defer d.mu.Unlock() + d.logCalls = nil + d.getCalls = nil +} + +// TestRouterDispatch cobre a matriz de roteamento da SPEC §5.1. +func TestRouterDispatch(t *testing.T) { + cases := []struct { + name string + subject string + hasNet bool + extra []string + wantWhere string // "network" | "file" | "noop" + }{ + {name: "leveled info → network", subject: "info", hasNet: true, wantWhere: "network"}, + {name: "leveled debug → network", subject: "debug", hasNet: true, wantWhere: "network"}, + {name: "leveled warn → network", subject: "warn", hasNet: true, wantWhere: "network"}, + {name: "leveled error → network", subject: "error", hasNet: true, wantWhere: "network"}, + {name: "leveled fatal → network", subject: "fatal", hasNet: true, wantWhere: "network"}, + {name: "leveled test → network", subject: "test", hasNet: true, wantWhere: "network"}, + {name: "domain subject → file", subject: "historico_posicao_taxi", hasNet: true, wantWhere: "file"}, + {name: "extender adds domain → network", subject: "historico_posicao_taxi", hasNet: true, extra: []string{"historico_posicao_taxi"}, wantWhere: "network"}, + {name: "extender empty entry ignored", subject: "", hasNet: true, extra: []string{""}, wantWhere: "noop"}, + {name: "leveled info, no network → file", subject: "info", hasNet: false, wantWhere: "file"}, + {name: "domain subject, no network → file", subject: "historico_posicao_taxi", hasNet: false, wantWhere: "file"}, + {name: "empty subject → noop", subject: "", hasNet: true, wantWhere: "noop"}, + {name: "empty subject, no network → noop", subject: "", hasNet: false, wantWhere: "noop"}, + {name: "case-sensitive: INFO not in whitelist → file", subject: "INFO", hasNet: true, wantWhere: "file"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + file := newRecorder("file", "file") + var network destination + if tc.hasNet { + network = newRecorder("net", "udp") + } + r := newRouterDestination(file, network, tc.extra) + + r.LogSubject(tc.subject, []byte(`{"message":"x"}`), nil) + + fileGot := file.logged() + var netGot []string + if rec, ok := network.(*recordingDestination); ok { + netGot = rec.logged() + } + + switch tc.wantWhere { + case "network": + if len(netGot) != 1 || netGot[0] != tc.subject { + t.Errorf("network impl got %v, want [%q]", netGot, tc.subject) + } + if len(fileGot) != 0 { + t.Errorf("file impl got %v, want empty", fileGot) + } + case "file": + if len(fileGot) != 1 || fileGot[0] != tc.subject { + t.Errorf("file impl got %v, want [%q]", fileGot, tc.subject) + } + if len(netGot) != 0 { + t.Errorf("network impl got %v, want empty", netGot) + } + case "noop": + if len(fileGot) != 0 || len(netGot) != 0 { + t.Errorf("expected no calls, got file=%v network=%v", fileGot, netGot) + } + } + }) + } +} + +// TestRouterGetFileNameFromStreamNameMatrix garante que o descritor é +// resolvido pelo impl que receberia o LogSubject. +func TestRouterGetFileNameFromStreamNameMatrix(t *testing.T) { + file := newRecorder("file", "file") + network := newRecorder("net", "udp") + r := newRouterDestination(file, network, []string{"historico_posicao_taxi"}) + + cases := map[string]string{ + "info": "udp:info", + "historico_posicao_taxi": "udp:historico_posicao_taxi", + "log_posicao_alterada": "file:log_posicao_alterada", + "": "", + } + for subj, want := range cases { + if got := r.GetFileNameFromStreamName(subj); got != want { + t.Errorf("GetFileNameFromStreamName(%q) = %q want %q", subj, got, want) + } + } + + // Quando network é nil, todos os subjects resolvem via file. + r2 := newRouterDestination(file, nil, nil) + if got := r2.GetFileNameFromStreamName("info"); got != "file:info" { + t.Errorf("router without network: GetFileNameFromStreamName(info) = %q want file:info", got) + } +} + +// TestRouterCloseBoth garante que Close fecha file e network exatamente +// uma vez, independente de erros. +func TestRouterCloseBoth(t *testing.T) { + file := newRecorder("file", "file") + network := newRecorder("net", "udp") + network.closeErr = errors.New("net failed") + + r := newRouterDestination(file, network, nil) + if err := r.Close(); err == nil { + t.Fatalf("expected non-nil error from Close (network failure should propagate)") + } + if file.closeCalls != 1 { + t.Errorf("file.Close calls = %d want 1", file.closeCalls) + } + if network.closeCalls != 1 { + t.Errorf("network.Close calls = %d want 1", network.closeCalls) + } +} + +// TestRouterCloseNetworkNil garante que Close funciona quando não há +// network configurado. +func TestRouterCloseNetworkNil(t *testing.T) { + file := newRecorder("file", "file") + r := newRouterDestination(file, nil, nil) + if err := r.Close(); err != nil { + t.Errorf("Close = %v want nil", err) + } + if file.closeCalls != 1 { + t.Errorf("file.Close calls = %d want 1", file.closeCalls) + } +} + +// TestRouterCloseNilReceiver garante que Close em receiver nil é no-op. +func TestRouterCloseNilReceiver(t *testing.T) { + var r *routerDestination + if err := r.Close(); err != nil { + t.Errorf("nil Close = %v want nil", err) + } +} + +// TestRouterLogSubjectNilReceiver garante que LogSubject em receiver +// nil não panica (defensivo; o facade já tem outro guard). +func TestRouterLogSubjectNilReceiver(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("nil receiver panicked: %v", r) + } + }() + var r *routerDestination + r.LogSubject("info", nil, nil) + if got := r.GetFileNameFromStreamName("info"); got != "" { + t.Errorf("nil GetFileNameFromStreamName = %q want empty", got) + } +} + +// TestEndToEndLeveledHitsUDPNotFile dispara LogSubject("info", …) com +// network configurado e verifica que: (1) datagrama chega ao listener +// UDP; (2) o arquivo .../info/info.log NÃO é criado em disco. +func TestEndToEndLeveledHitsUDPNotFile(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }}); err != nil { + t.Fatalf("Configure: %v", err) + } + + dir := t.TempDir() + 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":"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", err) + } + if got["short_message"] != "hello" { + t.Errorf("short_message=%v want hello", got["short_message"]) + } + + filePath := filepath.Join(dir, "svc", "info", "info.log") + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Errorf("info.log should NOT have been created at %q (err=%v)", filePath, err) + } +} + +// TestEndToEndDomainHitsFileNotUDP dispara LogSubject("historico_posicao_taxi", …) +// com network configurado e verifica que: (1) o arquivo é criado em +// disco com a shape do V2; (2) NÃO chega datagrama no listener UDP. +func TestEndToEndDomainHitsFileNotUDP(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }}); err != nil { + t.Fatalf("Configure: %v", err) + } + + dir := t.TempDir() + 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":"pos_event","level":"info","source":"x.go","line":"1","trace":""}`) + MchLog.LogSubject("historico_posicao_taxi", payload, nil) + + expectNoDatagram(t, conn, 200*time.Millisecond) + + filePath := filepath.Join(dir, "svc", "historico_posicao_taxi", "historico_posicao_taxi.log") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("expected file at %q: %v", filePath, err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 || lines[0] == "" { + t.Fatalf("no log lines written to %q", filePath) + } + 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]) + } + if got["message"] != "pos_event" { + t.Errorf("file content: message=%v want pos_event", got["message"]) + } +} + +// TestEndToEndNetworkSubjectsExtender garante que um subject de domínio +// listado em NetworkSubjects é roteado ao UDP em vez de ao disco. +func TestEndToEndNetworkSubjectsExtender(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{ + Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }, + NetworkSubjects: []string{"historico_posicao_taxi"}, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + + dir := t.TempDir() + 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":"pos","level":"info","source":"x.go","line":"1","trace":""}`) + MchLog.LogSubject("historico_posicao_taxi", 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 got["short_message"] != "pos" { + t.Errorf("short_message=%v want pos", got["short_message"]) + } + + filePath := filepath.Join(dir, "svc", "historico_posicao_taxi", "historico_posicao_taxi.log") + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Errorf("extender subject should not produce file at %q (err=%v)", filePath, err) + } +} + +// TestFacadeGetFileNameFromStreamNameRouting confirma que o facade +// expõe a regra do router: leveled → udp://…; domain → file path. +func TestFacadeGetFileNameFromStreamNameRouting(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }}); err != nil { + t.Fatalf("Configure: %v", err) + } + dir := t.TempDir() + servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + if got, want := MchLog.GetFileNameFromStreamName("info"), "udp://"+addr+"/info"; got != want { + t.Errorf("leveled descriptor = %q want %q", got, want) + } + if got, want := MchLog.GetFileNameFromStreamName("historico_posicao_taxi"), + filepath.Join(dir, "svc", "historico_posicao_taxi", "historico_posicao_taxi.log"); got != want { + t.Errorf("domain descriptor = %q want %q", got, want) + } +} + +// TestRouterConcurrentLogSubject roda muitas goroutines escrevendo via +// router (leveled+domain) e exige (a) zero races; (b) datagramas +// recebidos = N_leveled; (c) linhas no arquivo de domínio = N_domain. +func TestRouterConcurrentLogSubject(t *testing.T) { + t.Cleanup(resetConfig) + + addr, conn := listenUDP(t) + defer conn.Close() + // Buffer maior para evitar drops sob carga em loopback. + if u, ok := conn.(*net.UDPConn); ok { + _ = u.SetReadBuffer(1 << 20) + } + + if err := Configure(DestinationConfig{Network: &NetworkConfig{ + Type: NetworkGraylogUDP, + Addr: addr, + Source: "pod-1", + }}); err != nil { + t.Fatalf("Configure: %v", err) + } + dir := t.TempDir() + servicePath := filepath.Join(dir, "svc") + string(filepath.Separator) + if err := Initialize(servicePath); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + const goroutines = 20 + const perGoroutine = 20 + // Subject único deste teste para não colidir com o cache global do V2 + // (mchlogcorev2 cacheia um *zerolog.Logger por subject, com referência + // ao FD; se outro teste usar o mesmo subject em tempdir diferente, o + // cache aponta para o FD antigo, que ficou unlinked). + const domainSubject = "concurrent_router_domain_subject" + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func() { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + MchLog.LogSubject("info", []byte(`{"message":"x"}`), nil) + MchLog.LogSubject(domainSubject, []byte(`{"message":"x"}`), nil) + } + }() + } + wg.Wait() + + // Drena datagramas UDP por até 1s. Loopback pode dropar sob + // rajada; aceitamos qualquer N > 0 como sinal de que rota network + // está viva, e cobrimos a contagem exata via inspeção de arquivo. + gotDatagrams := 0 + _ = conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + buf := make([]byte, 64*1024) + for { + if _, _, err := conn.ReadFrom(buf); err != nil { + break + } + gotDatagrams++ + } + if gotDatagrams == 0 { + t.Errorf("expected at least 1 UDP datagram for leveled subjects, got 0") + } + + filePath := filepath.Join(dir, "svc", domainSubject, domainSubject+".log") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("expected file at %q: %v", filePath, err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + want := goroutines * perGoroutine + if len(lines) != want { + t.Errorf("file line count = %d want %d", len(lines), want) + } +} + +// expectNoDatagram verifica que nenhum datagrama chega à conn dentro +// do timeout. Helper local para evitar duplicar a lógica. +func expectNoDatagram(t *testing.T, conn net.PacketConn, timeout time.Duration) { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + buf := make([]byte, 4096) + if n, _, err := conn.ReadFrom(buf); err == nil { + t.Fatalf("unexpected datagram (%d bytes): %s", n, string(buf[:n])) + } +} From 546004c3067efc11b1923d80ac8a2c2b33144b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Wed, 20 May 2026 14:26:02 -0300 Subject: [PATCH 2/3] docs(mchlogcorev3): describe subject router model in README and facade V3 README section rewritten around the router: explains level-vs-domain routing, exclusive dispatch, fail-loud on UDP init, NetworkSubjects extender semantics, and migration path. mchlogcore.InitializeMchLog doc comment updated to reflect that V3 always has a file impl and uses the path's last segment for service name on the network side. --- README.md | 84 +++++++++++++++++++++++++++++--------------- mchlogcore/mchlog.go | 7 ++-- 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4488baf..741b666 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,28 @@ if err != nil { } ``` -## 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. +## V3 - Destino unificado (roteador por subject) +A V3 é o destino unificado da toolkit. Internamente roteia cada chamada +`LogSubject` por **subject**: -A V3 é a forma recomendada daqui em diante. V1 e V2 continuam disponíveis para retrocompatibilidade enquanto serviços migram. +- **Subjects "level-like"** — `test`, `debug`, `info`, `warn`, `error`, + `fatal`, ou seja, a saída de `logger.Info/.Warn/.Error/...` — vão para o + **destino de rede** (Graylog UDP/GELF) quando configurado; caso + contrário, caem em arquivo. +- **Subjects de domínio** — qualquer outra string passada a `LogSubject` + — vão **sempre** para arquivo, no mesmo layout do V2 + (`///.log`). -### Modo arquivo (`ProtocolFile`) -Comportamento idêntico ao V2: layout `///.log`, mesma JSON shape (`message`, `level`, `source`, `line`, `trace`, `timestamp`). +Roteamento é **exclusivo** (um subject vai para um único destino) e +**fail-loud**: se o destino de rede está configurado mas a inicialização +falha, o caller recebe o erro — não há fallback automático para arquivo. + +A V3 é a forma recomendada daqui em diante. V1 e V2 continuam disponíveis +para retrocompatibilidade enquanto serviços migram. + +### Modo arquivo-only +Comportamento idêntico ao V2: layout `///.log`, +mesma JSON shape (`message`, `level`, `source`, `line`, `trace`, `timestamp`). ```go import ( mchlogtoolkitgo "github.com/gaudiumsoftware/mchlogtoolkitgo" @@ -148,9 +163,7 @@ import ( ) func main() { - if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ - Protocol: mchlogcorev3.ProtocolFile, - }); err != nil { + if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{}); err != nil { panic(err) } mchlogcore.SetVersion(mchlogcore.V3) @@ -161,8 +174,9 @@ func main() { } ``` -### Modo Graylog UDP (`ProtocolGraylogUDP`) -Para `dev`/`qa` que centralizam logs no Graylog em vez de arquivo local: +### Modo roteador (arquivo + Graylog UDP) +Para serviços que querem level-logs no Graylog mantendo eventos de +domínio em disco: ```go import ( "os" @@ -174,10 +188,13 @@ import ( func main() { if err := mchlogcorev3.Configure(mchlogcorev3.DestinationConfig{ - Protocol: mchlogcorev3.ProtocolGraylogUDP, - Addr: "graylog.dev.internal:12201", - Source: "payments-api-qa-" + os.Getenv("POD_NAME"), - // DisableGZIP: true, // opcional, default = compressão habilitada + Network: &mchlogcorev3.NetworkConfig{ + Type: mchlogcorev3.NetworkGraylogUDP, + Addr: "graylog.dev.internal:12201", + Source: "payments-api-qa-" + os.Getenv("POD_NAME"), + // DisableGZIP: true, // opcional, default = compressão habilitada + }, + // NetworkSubjects: []string{"meu_subject_custom"}, // opcional }); err != nil { panic(err) } @@ -185,19 +202,23 @@ func main() { logger, _ := mchlogtoolkitgo.NewLogger("payments-api", "debug") logger.Initialize() - logger.Info("aplicação iniciada e ouvindo na porta 80") + logger.Info("aplicação iniciada e ouvindo na porta 80") // → Graylog + // Eventos de domínio continuam indo para arquivo: + // mchlogcorev3.MchLog.LogSubject("meu_evento_dominio", payload, nil) } ``` ### Campos do `DestinationConfig` -| 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 (modo `ProtocolGraylogUDP`) +| Campo | Obrigatório quando… | Descrição | +|------------------------|----------------------------------|--------------------------------------------------------------------------------------| +| `Network` | nunca (opcional) | Quando `nil`, todos os subjects vão para arquivo. Quando definido, subjects level-like vão para esse destino. | +| `Network.Type` | `Network != nil` | Único valor suportado hoje: `NetworkGraylogUDP`. | +| `Network.Addr` | `Type = NetworkGraylogUDP` | Endereço do Graylog no formato `host:porta`. | +| `Network.Source` | `Type = NetworkGraylogUDP` | 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. | +| `Network.DisableGZIP` | nunca (opcional) | Default `false` (gzip habilitado). | +| `NetworkSubjects` | requer `Network != nil` | Lista extra de subjects que devem ir para o destino de rede em vez de arquivo. Match exato, case-sensitive. **Cuidado:** subjects usados por healthchecks ou probes (qualquer caller que chame `GetFileNameFromStreamName` esperando um caminho de arquivo) devem ficar fora desta lista. | + +### Como aparece no Graylog | GELF field | Origem | Coluna/campo no Graylog | |---------------------|----------------------------------------------|-------------------------| | `host` | `cfg.Source` | `source` (default) | @@ -214,16 +235,23 @@ Exemplos de busca: - `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 (modo `ProtocolGraylogUDP`) +### Falhas de envio (subjects roteados ao Graylog) 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. +Eventos de domínio em arquivo seguem sendo gravados normalmente — file é +fonte de verdade para dados persistentes. ### 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. +- **Produção** e qualquer ambiente que precise rastreabilidade de + eventos de domínio: configure `Network` para receber level-logs no + Graylog e mantenha o file destination (sempre presente) gravando os + eventos de domínio. +- **Sem Graylog (legado/local)**: `Configure(DestinationConfig{})` — + modo arquivo-only, idêntico ao V2. ### Migração de V1/V2 para V3 -Trocar `mchlogcore.SetVersion(mchlogcore.V2)` por `Configure(DestinationConfig{Protocol: ProtocolFile}) + SetVersion(V3)` mantém o comportamento bit-a-bit (mesmo layout, mesma JSON shape). +Trocar `mchlogcore.SetVersion(mchlogcore.V2)` por +`Configure(DestinationConfig{}) + 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 66db644..07fa0ec 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -108,9 +108,10 @@ var MchLog LogType // 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. +// - V1 e V2 usam o caminho como diretório base de arquivos. +// - V3 usa o caminho como diretório base do file impl (sempre presente) +// e também extrai o nome do serviço do último segmento para uso no +// destino de rede (quando configurado em DestinationConfig.Network). func InitializeMchLog(path string) { var versionName string var initErr error From 11b8eac8b8aff140c032e2080d71fe7561ed4cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Veiga?= Date: Wed, 20 May 2026 14:39:01 -0300 Subject: [PATCH 3/3] chore(mchlogcorev3): address review nits from PR #18 - ActiveConfig now deep-copies Network ptr and NetworkSubjects slice, symmetric with Configure. Callers may mutate the result freely. - defaultLevelSubjects comment documents case-sensitivity (Logger internal always emits lowercase). - TestRouterConcurrentLogSubject doc + inline comment explain why the UDP datagram check accepts N > 0: loopback drops under bursts, exact total is validated via the domain-subject file (deterministic). --- mchlogcorev3/config.go | 22 ++++++++++++++++------ mchlogcorev3/router.go | 4 +++- mchlogcorev3/router_test.go | 12 ++++++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go index b75cbcb..ebde6e5 100644 --- a/mchlogcorev3/config.go +++ b/mchlogcorev3/config.go @@ -119,17 +119,27 @@ func Configure(cfg DestinationConfig) error { return nil } -// ActiveConfig retorna uma cópia da configuração ativa. Útil para -// testes e para o destino ler os parâmetros já normalizados. +// ActiveConfig retorna uma cópia profunda da configuração ativa. Útil +// para testes e para o destino ler os parâmetros já normalizados. // Antes de Configure ser chamado, devolve um DestinationConfig zero-valued. // -// Atenção: o ponteiro Network é compartilhado com a cópia interna. -// Callers que mutarem *ActiveConfig().Network corromperão o estado; -// trate-o como read-only. +// O ponteiro Network e o slice NetworkSubjects são duplicados, então +// callers podem mutar o resultado livremente sem afetar o estado interno +// (simetria com Configure, que também duplica ambos na entrada). func ActiveConfig() DestinationConfig { cfgMu.RLock() defer cfgMu.RUnlock() - return activeCfg + out := activeCfg + if activeCfg.Network != nil { + n := *activeCfg.Network + out.Network = &n + } + if len(activeCfg.NetworkSubjects) > 0 { + dup := make([]string, len(activeCfg.NetworkSubjects)) + copy(dup, activeCfg.NetworkSubjects) + out.NetworkSubjects = dup + } + return out } // IsConfigured indica se Configure já foi chamado com sucesso. diff --git a/mchlogcorev3/router.go b/mchlogcorev3/router.go index 5fdd90d..121fdca 100644 --- a/mchlogcorev3/router.go +++ b/mchlogcorev3/router.go @@ -5,7 +5,9 @@ package mchlogcorev3 // para o network impl quando este está configurado. Subjects fora desta // lista (eventos de domínio) vão para o file impl. // -// Para estender a whitelist, use DestinationConfig.NetworkSubjects. +// Match é case-sensitive: o Logger interno sempre emite o level em +// lowercase, então `LogSubject("INFO", ...)` cai em file (não bate na +// whitelist). Para estender, use DestinationConfig.NetworkSubjects. var defaultLevelSubjects = []string{ "test", "debug", "info", "warn", "error", "fatal", } diff --git a/mchlogcorev3/router_test.go b/mchlogcorev3/router_test.go index b658f86..3e179cc 100644 --- a/mchlogcorev3/router_test.go +++ b/mchlogcorev3/router_test.go @@ -401,7 +401,9 @@ func TestFacadeGetFileNameFromStreamNameRouting(t *testing.T) { // TestRouterConcurrentLogSubject roda muitas goroutines escrevendo via // router (leveled+domain) e exige (a) zero races; (b) datagramas -// recebidos = N_leveled; (c) linhas no arquivo de domínio = N_domain. +// recebidos > 0 (UDP em loopback pode dropar sob rajada — contagem +// exata não é validável aqui); (c) linhas no arquivo de domínio = +// N_domain (file é determinístico, valida o total exato). func TestRouterConcurrentLogSubject(t *testing.T) { t.Cleanup(resetConfig) @@ -447,9 +449,11 @@ func TestRouterConcurrentLogSubject(t *testing.T) { } wg.Wait() - // Drena datagramas UDP por até 1s. Loopback pode dropar sob - // rajada; aceitamos qualquer N > 0 como sinal de que rota network - // está viva, e cobrimos a contagem exata via inspeção de arquivo. + // Drena datagramas UDP por até 1s. UDP em loopback dropa sob rajada + // — não dá para exigir N exato. Aceitamos N > 0 como sinal de que a + // rota network está viva; a validação determinística do total é + // feita logo abaixo via inspeção do arquivo do subject de domínio + // (file impl não dropa). gotDatagrams := 0 _ = conn.SetReadDeadline(time.Now().Add(1 * time.Second)) buf := make([]byte, 64*1024)