diff --git a/README.md b/README.md index b1785da..4488baf 100644 --- a/README.md +++ b/README.md @@ -131,4 +131,99 @@ if err != nil { logger.Fatal("Error connecting to mysql: " + err.Error()) os.Exit(1) } -``` \ No newline at end of file +``` + +## 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. + +### 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.DestinationConfig{ + 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" + + mchlogtoolkitgo "github.com/gaudiumsoftware/mchlogtoolkitgo" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcore" + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev3" +) + +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 + }); 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 `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`) +| 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) | `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`. +- `source:*-qa-*` — todos os pods de QA (env embutido em `Source` pelo caller). + +### 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 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(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/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/mchlogcore/mchlog.go b/mchlogcore/mchlog.go index 16e32e8..66db644 100644 --- a/mchlogcore/mchlog.go +++ b/mchlogcore/mchlog.go @@ -1,70 +1,141 @@ 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 destino +// 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 is a type to define which version of the logger to use +// LogVersion identifica qual destino 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 — destino 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 — destino de arquivo, formato simples (um arquivo por subject). V2 + // 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. + V3 ) -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) +} -// SetVersion chooses which version to use (V1 or V2). -// This should ideally be called before InitializeMchLog. +// transportFor mapeia uma LogVersion para o Transport correspondente. +// Centraliza o dispatch: adicionar uma nova versão significa estender +// apenas este switch. +// +// 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 + case V2: + return &mchlogcorev2.MchLog + default: + return &mchlogcorev1.MchLog + } +} + +// 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 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() + } + 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 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. func InitializeMchLog(path string) { - versionName := "V1" - if currentVersion == V1 { - mchlogcorev1.InitializeMchLog(path) - } else { + 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 } - // 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) + } +} diff --git a/mchlogcore/transport.go b/mchlogcore/transport.go new file mode 100644 index 0000000..9390443 --- /dev/null +++ b/mchlogcore/transport.go @@ -0,0 +1,30 @@ +package mchlogcore + +// Transport é a estratégia que efetivamente persiste ou envia os logs. +// 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 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 destinos 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: 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 +// 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. + Close() error +} diff --git a/mchlogcore/v3_dispatch_test.go b/mchlogcore/v3_dispatch_test.go new file mode 100644 index 0000000..b76b7cf --- /dev/null +++ b/mchlogcore/v3_dispatch_test.go @@ -0,0 +1,102 @@ +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.DestinationConfig{ + Protocol: mchlogcorev3.ProtocolGraylogUDP, + 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"]) + } +} diff --git a/mchlogcorev3/config.go b/mchlogcorev3/config.go new file mode 100644 index 0000000..4a0793a --- /dev/null +++ b/mchlogcorev3/config.go @@ -0,0 +1,128 @@ +// Package mchlogcorev3 é o destino unificado da toolkit. Suporta múltiplos +// protocolos selecionados por DestinationConfig.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 ( + "errors" + "os" + "sync" +) + +// Protocol identifica o destino 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" +) + +// 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 + + // 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). + Source string + + // 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 DestinationConfig + configured bool +) + +// 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. +func Configure(cfg DestinationConfig) error { + if cfg.Protocol == "" { + 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: + 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)) + } + + 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 destino ler os parâmetros já normalizados. +// Antes de Configure ser chamado, devolve um DestinationConfig zero-valued. +func ActiveConfig() DestinationConfig { + 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. Útil apenas para +// ProtocolGraylogUDP. +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). +// +// 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{} + configured = false + cfgMu.Unlock() +} diff --git a/mchlogcorev3/config_test.go b/mchlogcorev3/config_test.go new file mode 100644 index 0000000..86a3a00 --- /dev/null +++ b/mchlogcorev3/config_test.go @@ -0,0 +1,125 @@ +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") + } +} + +// 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) + + 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 { + 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(DestinationConfig{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(DestinationConfig{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(DestinationConfig{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(DestinationConfig{ + 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 = %q", got.Protocol) + } + if got.DisableGZIP { + t.Errorf("GZIP must be enabled by default (DisableGZIP=false)") + } + if got.Addr != "graylog.dev:12201" { + t.Errorf("Addr = %q", got.Addr) + } +} + +// TestConfigureDisableGZIPRespected garante que callers podem desabilitar +// gzip explicitamente. +func TestConfigureDisableGZIPRespected(t *testing.T) { + t.Cleanup(resetConfig) + + if err := Configure(DestinationConfig{ + Protocol: ProtocolGraylogUDP, + 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") + } +} + +// TestConfigureRejectsUnknownProtocol garante futuro-proofing para +// quando outros protocolos forem adicionados. +func TestConfigureRejectsUnknownProtocol(t *testing.T) { + t.Cleanup(resetConfig) + + err := Configure(DestinationConfig{Protocol: "graylog-tcp"}) + 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) + } + if IsConfigured() { + t.Errorf("IsConfigured should be false before Configure") + } +} diff --git a/mchlogcorev3/coverage_test.go b/mchlogcorev3/coverage_test.go new file mode 100644 index 0000000..1a0d1e6 --- /dev/null +++ b/mchlogcorev3/coverage_test.go @@ -0,0 +1,250 @@ +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(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) + } + 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(DestinationConfig{Protocol: ProtocolGraylogUDP, 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(DestinationConfig{Protocol: ProtocolGraylogUDP, 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(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) + } + 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(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) + } + 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 e +// rejeita paths degenerados ("./", ".", "..", roots Windows). +func TestServiceFromPathVariants(t *testing.T) { + cases := map[string]string{ + "/applog/payments-api/": "payments-api", + "/applog/svc": "svc", + "./applog/svc/": "svc", + "": "", + "/": "", + "./": "", + ".": "", + "..": "", + "C:/": "", + "C:\\": "", + } + 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) + } +} diff --git a/mchlogcorev3/failure_test.go b/mchlogcorev3/failure_test.go new file mode 100644 index 0000000..280ac05 --- /dev/null +++ b/mchlogcorev3/failure_test.go @@ -0,0 +1,247 @@ +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(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) + } + 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 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) + 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) + } + 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(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) + } + t.Cleanup(func() { _ = MchLog.Close() }) + + got := captureStderr(t, func() { + 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.mu.Lock() + g.lastWarn = time.Time{} + g.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(DestinationConfig{Protocol: ProtocolGraylogUDP, 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") + } +} + +// 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) + } +} diff --git a/mchlogcorev3/file.go b/mchlogcorev3/file.go new file mode 100644 index 0000000..3d1bc8d --- /dev/null +++ b/mchlogcorev3/file.go @@ -0,0 +1,51 @@ +package mchlogcorev3 + +import ( + "github.com/gaudiumsoftware/mchlogtoolkitgo/mchlogcorev2" +) + +// fileDestination é 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 fileDestination struct { + inner *mchlogcorev2.LogType +} + +// newFileDestination inicializa o V2 subjacente com o path recebido e +// devolve um wrapper pronto para uso. +func newFileDestination(path string) *fileDestination { + mchlogcorev2.InitializeMchLog(path) + return &fileDestination{inner: &mchlogcorev2.MchLog} +} + +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 *fileDestination) 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. +// +// 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 { + return nil +} diff --git a/mchlogcorev3/file_test.go b/mchlogcorev3/file_test.go new file mode 100644 index 0000000..119e3ac --- /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(DestinationConfig{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(DestinationConfig{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(DestinationConfig{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(DestinationConfig{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) } diff --git a/mchlogcorev3/gelf.go b/mchlogcorev3/gelf.go new file mode 100644 index 0000000..8581457 --- /dev/null +++ b/mchlogcorev3/gelf.go @@ -0,0 +1,181 @@ +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 DestinationConfig) (*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().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: + // 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 { + 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..cd618ab --- /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, DestinationConfig{ + 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, DestinationConfig{ + 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"), DestinationConfig{ + 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, DestinationConfig{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, DestinationConfig{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, DestinationConfig{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, DestinationConfig{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) + } +} diff --git a/mchlogcorev3/integration_test.go b/mchlogcorev3/integration_test.go new file mode 100644 index 0000000..2dbf185 --- /dev/null +++ b/mchlogcorev3/integration_test.go @@ -0,0 +1,43 @@ +//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(DestinationConfig{ + Protocol: ProtocolGraylogUDP, + 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. +} diff --git a/mchlogcorev3/mchlogv3.go b/mchlogcorev3/mchlogv3.go new file mode 100644 index 0000000..6f24913 --- /dev/null +++ b/mchlogcorev3/mchlogv3.go @@ -0,0 +1,227 @@ +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 falha. Uma falha gera no máximo um aviso +// por janela. +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. +type destination interface { + LogSubject(subject string, content any, errLog error, ascendStackFrame ...int) + GetFileNameFromStreamName(subject string) string + Close() error +} + +// 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 destination +} + +// 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) +} + +// 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 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") + } + + service := serviceFromPath(path) + if service == "" { + return errors.New("mchlogcorev3: cannot extract service name from path: " + path) + } + + 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) + } + 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)) + } + + // 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 +} + +// graylogUDP envia logs em formato GELF via UDP. +type graylogUDP struct { + writer *gelf.Writer + cfg DestinationConfig + serviceName string + + mu sync.Mutex + closed bool + 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 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) { + _ = ascendStackFrame + + 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) + } +} + +func (g *graylogUDP) GetFileNameFromStreamName(subject string) string { + if g == nil { + return "" + } + return "udp://" + g.cfg.Addr + "/" + subject +} + +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). +// 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 { + // 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 "" + } + 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 new file mode 100644 index 0000000..67566b7 --- /dev/null +++ b/mchlogcorev3/mchlogv3_test.go @@ -0,0 +1,196 @@ +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(DestinationConfig{ + Protocol: ProtocolGraylogUDP, + 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(DestinationConfig{Protocol: ProtocolGraylogUDP, 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(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) + } + + 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") + } +} + +// 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() }) +}