chore: remove dead code and unused exports
Remove ~900 lines of unused code identified by static analysis: Go: - internal/logging: Remove 10 unused functions (InitFromConfig, New, FromContext, WithLogger, etc.) that were built but never integrated - cmd/pulse-sensor-proxy: Remove 7 dead validation functions for a removed command execution feature - internal/metrics: Remove 8 unused notification metric functions and 10 Prometheus metrics that were never wired up Frontend: - Delete ActivationBanner.tsx stub component - Remove unused exports: stopMetricsSampler, getSamplerStatus, formatSpeedCompact, parseMetricKey, getResourceAlerts
This commit is contained in:
parent
244431b6c2
commit
f7ffb36c41
12 changed files with 116 additions and 993 deletions
|
|
@ -9,8 +9,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -25,13 +23,6 @@ var (
|
||||||
ipv4Regex = regexp.MustCompile(`^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$`)
|
ipv4Regex = regexp.MustCompile(`^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
allowedCommands = map[string]struct{}{
|
|
||||||
"sensors": {},
|
|
||||||
"ipmitool": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// sanitizeCorrelationID validates and sanitizes a correlation ID
|
// sanitizeCorrelationID validates and sanitizes a correlation ID
|
||||||
// Returns a valid UUID, generating a new one if input is missing or invalid
|
// Returns a valid UUID, generating a new one if input is missing or invalid
|
||||||
func sanitizeCorrelationID(id string) string {
|
func sanitizeCorrelationID(id string) string {
|
||||||
|
|
@ -70,142 +61,6 @@ func validateNodeName(name string) error {
|
||||||
return fmt.Errorf("invalid node name")
|
return fmt.Errorf("invalid node name")
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCommand(name string, args []string) error {
|
|
||||||
if err := validateCommandName(name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, arg := range args {
|
|
||||||
if err := validateCommandArg(arg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "ipmitool" {
|
|
||||||
if err := validateIPMIToolArgs(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCommandName(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
return errors.New("command required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(name, "/") {
|
|
||||||
return errors.New("absolute command paths not allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := allowedCommands[name]; !ok {
|
|
||||||
return fmt.Errorf("command %q not permitted", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isASCII(name) {
|
|
||||||
return errors.New("command must be ASCII")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCommandArg(arg string) error {
|
|
||||||
if len(arg) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(arg) > 1024 {
|
|
||||||
return errors.New("argument too long")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !utf8.ValidString(arg) {
|
|
||||||
return errors.New("argument contains invalid UTF-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasNullByte(arg) {
|
|
||||||
return errors.New("argument contains null byte")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isASCII(arg) {
|
|
||||||
return errors.New("argument must be ASCII")
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasShellMeta(arg) {
|
|
||||||
return errors.New("argument contains forbidden shell characters")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(arg, "=") && !strings.HasPrefix(arg, "-") {
|
|
||||||
return errors.New("environment-style arguments not permitted")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateIPMIToolArgs(args []string) error {
|
|
||||||
lowered := make([]string, len(args))
|
|
||||||
for i, arg := range args {
|
|
||||||
lowered[i] = strings.ToLower(arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(lowered); i++ {
|
|
||||||
token := lowered[i]
|
|
||||||
switch token {
|
|
||||||
case "shell", "raw", "exec", "lanplus", "lanplusciphers":
|
|
||||||
return errors.New("dangerous ipmitool arguments not permitted")
|
|
||||||
case "chassis":
|
|
||||||
if i+1 < len(lowered) {
|
|
||||||
switch lowered[i+1] {
|
|
||||||
case "power", "bootparam", "status", "policy":
|
|
||||||
return errors.New("chassis operations not permitted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "power", "reset", "off", "cycle", "bmc", "mc":
|
|
||||||
return errors.New("power control commands not permitted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasShellMeta(s string) bool {
|
|
||||||
forbidden := []string{";", "|", "&", "$", "`", "\\", ">", "<", "(", ")", "[", "]", "{", "}", "!", "~"}
|
|
||||||
for _, ch := range forbidden {
|
|
||||||
if strings.Contains(s, ch) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(s, "..") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.ContainsAny(s, "\n\r\t") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(s, "-") && strings.Contains(s, "=") {
|
|
||||||
if strings.Contains(s, "/") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasNullByte(s string) bool {
|
|
||||||
return strings.IndexByte(s, 0) >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isASCII(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if r > unicode.MaxASCII {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
nodeValidatorCacheTTL = 5 * time.Minute
|
nodeValidatorCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func FuzzValidateCommand(f *testing.F) {
|
|
||||||
seeds := []string{
|
|
||||||
"sensors -j",
|
|
||||||
"ipmitool sdr",
|
|
||||||
"sensors",
|
|
||||||
"ipmitool lan print",
|
|
||||||
}
|
|
||||||
for _, seed := range seeds {
|
|
||||||
f.Add(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Fuzz(func(t *testing.T, input string) {
|
|
||||||
fields := strings.Fields(input)
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cmd := fields[0]
|
|
||||||
args := []string{}
|
|
||||||
if len(fields) > 1 {
|
|
||||||
args = fields[1:]
|
|
||||||
}
|
|
||||||
validateCommand(cmd, args) // ensure no panics
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -83,48 +83,6 @@ func TestValidateNodeName(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateCommand(t *testing.T) {
|
|
||||||
type tc struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
wantErr bool
|
|
||||||
desc string
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []tc{
|
|
||||||
{name: "sensors", args: nil, wantErr: false, desc: "bare sensors"},
|
|
||||||
{name: "sensors", args: []string{"-j"}, wantErr: false, desc: "json flag"},
|
|
||||||
{name: "ipmitool", args: []string{"sdr"}, wantErr: false, desc: "safe ipmitool"},
|
|
||||||
{name: "sensors", args: []string{"; rm -rf /"}, wantErr: true, desc: "shell metachar"},
|
|
||||||
{name: "sensors", args: []string{"$(id)"}, wantErr: true, desc: "subshell"},
|
|
||||||
{name: "ipmitool", args: []string{"-H", "1.2.3.4", "&&", "shutdown"}, wantErr: true, desc: "command chaining"},
|
|
||||||
{name: "sensors", args: []string{">/tmp/out"}, wantErr: true, desc: "redirect"},
|
|
||||||
{name: "senso\u200Brs", wantErr: true, desc: "unicode homoglyph"},
|
|
||||||
{name: "sensors", args: []string{"-" + strings.Repeat("v", 2000)}, wantErr: true, desc: "arg too long"},
|
|
||||||
{name: "sensors", args: []string{"test\x00"}, wantErr: true, desc: "null byte arg"},
|
|
||||||
{name: "ipmitool", args: []string{"chassis", "power", "off"}, wantErr: true, desc: "dangerous ipmitool"},
|
|
||||||
{name: "sensors", args: []string{"LC_ALL=C"}, wantErr: true, desc: "env prefix"},
|
|
||||||
{name: "/usr/bin/sensors", wantErr: true, desc: "absolute path"},
|
|
||||||
{name: "ipmitool", args: []string{"--extraneous=../../etc/passwd"}, wantErr: true, desc: "path traversal"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
if tc.desc == "" {
|
|
||||||
tc.desc = tc.name
|
|
||||||
}
|
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
|
||||||
err := validateCommand(tc.name, tc.args)
|
|
||||||
if tc.wantErr && err == nil {
|
|
||||||
t.Fatalf("expected error for %s %v", tc.name, tc.args)
|
|
||||||
}
|
|
||||||
if !tc.wantErr && err != nil {
|
|
||||||
t.Fatalf("unexpected error for %s %v: %v", tc.name, tc.args, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubResolver struct {
|
type stubResolver struct {
|
||||||
ips []net.IP
|
ips []net.IP
|
||||||
err error
|
err error
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import type { JSX } from 'solid-js';
|
|
||||||
import type { Alert } from '@/types/api';
|
|
||||||
import type { ActivationState, AlertConfig } from '@/types/alerts';
|
|
||||||
|
|
||||||
interface ActivationBannerProps {
|
|
||||||
activationState: () => ActivationState | null;
|
|
||||||
activeAlerts: () => Alert[] | undefined;
|
|
||||||
config: () => AlertConfig | null;
|
|
||||||
isPastObservationWindow: () => boolean;
|
|
||||||
isLoading: () => boolean;
|
|
||||||
refreshActiveAlerts: () => Promise<void>;
|
|
||||||
activate: () => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivationBanner(_props: ActivationBannerProps): JSX.Element {
|
|
||||||
// Notifications activation banner/modal intentionally disabled.
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
@ -156,25 +156,3 @@ export function startMetricsSampler(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the metrics sampler
|
|
||||||
*/
|
|
||||||
export function stopMetricsSampler(): void {
|
|
||||||
if (samplerInterval !== null) {
|
|
||||||
window.clearInterval(samplerInterval);
|
|
||||||
samplerInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isRunning = false;
|
|
||||||
logger.info('[MetricsSampler] Stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get sampler status
|
|
||||||
*/
|
|
||||||
export function getSamplerStatus() {
|
|
||||||
return {
|
|
||||||
isRunning,
|
|
||||||
intervalMs: SAMPLE_INTERVAL_MS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,3 @@ export const getAlertStyles = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get alert messages for a specific resource
|
|
||||||
export const getResourceAlerts = (
|
|
||||||
resourceId: string,
|
|
||||||
activeAlerts: Record<string, Alert>,
|
|
||||||
alertsEnabled: boolean | undefined = isAlertsActivationEnabled(),
|
|
||||||
): Alert[] => {
|
|
||||||
if (!alertsEnabled) return [];
|
|
||||||
return Object.values(activeAlerts).filter((alert) => alert.resourceId === resourceId);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,6 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
|
||||||
return `${formatBytes(bytesPerSecond, decimals)}/s`;
|
return `${formatBytes(bytesPerSecond, decimals)}/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSpeedCompact(bytesPerSecond: number): string {
|
|
||||||
if (!bytesPerSecond || bytesPerSecond < 0) return '0';
|
|
||||||
const k = 1024;
|
|
||||||
const mbps = bytesPerSecond / (k * k);
|
|
||||||
if (mbps < 1) return '<1';
|
|
||||||
if (mbps < 10) return mbps.toFixed(1);
|
|
||||||
return Math.round(mbps).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPercent(value: number): string {
|
export function formatPercent(value: number): string {
|
||||||
if (!Number.isFinite(value)) return '0%';
|
if (!Number.isFinite(value)) return '0%';
|
||||||
const abs = Math.abs(value);
|
const abs = Math.abs(value);
|
||||||
|
|
|
||||||
|
|
@ -17,20 +17,6 @@ export function buildMetricKey(kind: MetricResourceKind, id: string): string {
|
||||||
return `${kind}:${id}`;
|
return `${kind}:${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a metric key back into its components
|
|
||||||
* Returns null if the key format is invalid
|
|
||||||
*/
|
|
||||||
export function parseMetricKey(key: string): { kind: MetricResourceKind; id: string } | null {
|
|
||||||
const colonIndex = key.indexOf(':');
|
|
||||||
if (colonIndex === -1) return null;
|
|
||||||
|
|
||||||
const kind = key.slice(0, colonIndex) as MetricResourceKind;
|
|
||||||
const id = key.slice(colonIndex + 1);
|
|
||||||
|
|
||||||
return { kind, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the prefix from a metric resource kind
|
* Extract the prefix from a metric resource kind
|
||||||
* Used for bulk operations on a specific resource type
|
* Used for bulk operations on a specific resource type
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -23,7 +22,6 @@ type ctxKey string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
requestIDKey ctxKey = "logging_request_id"
|
requestIDKey ctxKey = "logging_request_id"
|
||||||
loggerKey ctxKey = "logging_logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config controls logger initialization.
|
// Config controls logger initialization.
|
||||||
|
|
@ -37,15 +35,6 @@ type Config struct {
|
||||||
Compress bool // gzip rotated logs
|
Compress bool // gzip rotated logs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option customizes logger construction.
|
|
||||||
type Option func(*options)
|
|
||||||
|
|
||||||
type options struct {
|
|
||||||
writer io.Writer
|
|
||||||
fields map[string]interface{}
|
|
||||||
withCaller bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
baseLogger zerolog.Logger
|
baseLogger zerolog.Logger
|
||||||
|
|
@ -90,148 +79,11 @@ func Init(cfg Config) zerolog.Logger {
|
||||||
return baseLogger
|
return baseLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitFromConfig initialises logging with environment overrides.
|
|
||||||
func InitFromConfig(ctx context.Context, cfg Config) (zerolog.Logger, error) {
|
|
||||||
if cfg.Level == "" {
|
|
||||||
cfg.Level = "info"
|
|
||||||
}
|
|
||||||
if cfg.Format == "" {
|
|
||||||
cfg.Format = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
if envLevel := os.Getenv("LOG_LEVEL"); envLevel != "" {
|
|
||||||
cfg.Level = envLevel
|
|
||||||
}
|
|
||||||
if envFormat := os.Getenv("LOG_FORMAT"); envFormat != "" {
|
|
||||||
cfg.Format = envFormat
|
|
||||||
}
|
|
||||||
if envFile := os.Getenv("LOG_FILE"); envFile != "" {
|
|
||||||
cfg.FilePath = envFile
|
|
||||||
}
|
|
||||||
if envSize := os.Getenv("LOG_MAX_SIZE"); envSize != "" {
|
|
||||||
if size, err := strconv.Atoi(envSize); err == nil {
|
|
||||||
cfg.MaxSizeMB = size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if envAge := os.Getenv("LOG_MAX_AGE"); envAge != "" {
|
|
||||||
if age, err := strconv.Atoi(envAge); err == nil {
|
|
||||||
cfg.MaxAgeDays = age
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if envCompress := os.Getenv("LOG_COMPRESS"); envCompress != "" {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(envCompress)) {
|
|
||||||
case "0", "false", "no":
|
|
||||||
cfg.Compress = false
|
|
||||||
default:
|
|
||||||
cfg.Compress = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isValidLevel(cfg.Level) {
|
|
||||||
return zerolog.Logger{}, fmt.Errorf("invalid log level %q: must be debug, info, warn, or error", cfg.Level)
|
|
||||||
}
|
|
||||||
|
|
||||||
format := strings.ToLower(strings.TrimSpace(cfg.Format))
|
|
||||||
if format != "" && format != "json" && format != "console" && format != "auto" {
|
|
||||||
return zerolog.Logger{}, fmt.Errorf("invalid log format %q: must be json, console, or auto", cfg.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := Init(cfg)
|
|
||||||
return logger, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLevelEnabled reports whether the provided level is enabled for logging.
|
// IsLevelEnabled reports whether the provided level is enabled for logging.
|
||||||
func IsLevelEnabled(level zerolog.Level) bool {
|
func IsLevelEnabled(level zerolog.Level) bool {
|
||||||
return level >= zerolog.GlobalLevel()
|
return level >= zerolog.GlobalLevel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a logger tailored to a specific component.
|
|
||||||
func New(component string, opts ...Option) zerolog.Logger {
|
|
||||||
cfg := collectOptions(opts...)
|
|
||||||
|
|
||||||
mu.RLock()
|
|
||||||
globalWriter := baseWriter
|
|
||||||
globalComponent := baseComponent
|
|
||||||
mu.RUnlock()
|
|
||||||
|
|
||||||
writer := cfg.writer
|
|
||||||
if writer == nil {
|
|
||||||
writer = globalWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
component = strings.TrimSpace(component)
|
|
||||||
if component == "" {
|
|
||||||
component = globalComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := zerolog.New(writer)
|
|
||||||
contextBuilder := logger.With().Timestamp()
|
|
||||||
|
|
||||||
if component != "" {
|
|
||||||
contextBuilder = contextBuilder.Str("component", component)
|
|
||||||
}
|
|
||||||
if len(cfg.fields) > 0 {
|
|
||||||
contextBuilder = contextBuilder.Fields(cfg.fields)
|
|
||||||
}
|
|
||||||
if cfg.withCaller {
|
|
||||||
contextBuilder = contextBuilder.Caller()
|
|
||||||
}
|
|
||||||
|
|
||||||
return contextBuilder.Logger()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithCaller enables caller logging.
|
|
||||||
func WithCaller() Option {
|
|
||||||
return func(o *options) {
|
|
||||||
o.withCaller = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWriter overrides the logger writer.
|
|
||||||
func WithWriter(w io.Writer) Option {
|
|
||||||
return func(o *options) {
|
|
||||||
if w != nil {
|
|
||||||
o.writer = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithFields adds static fields to the logger.
|
|
||||||
func WithFields(fields map[string]interface{}) Option {
|
|
||||||
return func(o *options) {
|
|
||||||
if len(fields) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if o.fields == nil {
|
|
||||||
o.fields = make(map[string]interface{}, len(fields))
|
|
||||||
}
|
|
||||||
for k, v := range fields {
|
|
||||||
o.fields[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FromContext returns a logger enriched with context metadata.
|
|
||||||
func FromContext(ctx context.Context) zerolog.Logger {
|
|
||||||
if ctx == nil {
|
|
||||||
return getBaseLogger()
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing, ok := ctx.Value(loggerKey).(zerolog.Logger); ok {
|
|
||||||
return enrichWithRequestID(existing, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
return enrichWithRequestID(getBaseLogger(), ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithLogger stores a logger on the context.
|
|
||||||
func WithLogger(ctx context.Context, logger zerolog.Logger) context.Context {
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
return context.WithValue(ctx, loggerKey, logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithRequestID stores (or generates) a request ID on the context.
|
// WithRequestID stores (or generates) a request ID on the context.
|
||||||
func WithRequestID(ctx context.Context, id string) (context.Context, string) {
|
func WithRequestID(ctx context.Context, id string) (context.Context, string) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
|
|
@ -244,27 +96,6 @@ func WithRequestID(ctx context.Context, id string) (context.Context, string) {
|
||||||
return context.WithValue(ctx, requestIDKey, id), id
|
return context.WithValue(ctx, requestIDKey, id), id
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRequestID retrieves the request ID from context.
|
|
||||||
func GetRequestID(ctx context.Context) string {
|
|
||||||
if ctx == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if id, ok := ctx.Value(requestIDKey).(string); ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectOptions(opts ...Option) options {
|
|
||||||
cfg := options{}
|
|
||||||
for _, opt := range opts {
|
|
||||||
if opt != nil {
|
|
||||||
opt(&cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseLevel(level string) zerolog.Level {
|
func parseLevel(level string) zerolog.Level {
|
||||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|
@ -278,15 +109,6 @@ func parseLevel(level string) zerolog.Level {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidLevel(level string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
|
||||||
case "debug", "info", "warn", "error":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectWriter(format string) io.Writer {
|
func selectWriter(format string) io.Writer {
|
||||||
format = strings.ToLower(strings.TrimSpace(format))
|
format = strings.ToLower(strings.TrimSpace(format))
|
||||||
switch format {
|
switch format {
|
||||||
|
|
@ -318,22 +140,6 @@ func isTerminal(file *os.File) bool {
|
||||||
return term.IsTerminal(int(file.Fd()))
|
return term.IsTerminal(int(file.Fd()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBaseLogger() zerolog.Logger {
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
return baseLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
func enrichWithRequestID(logger zerolog.Logger, ctx context.Context) zerolog.Logger {
|
|
||||||
if ctx == nil {
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
if id := GetRequestID(ctx); id != "" {
|
|
||||||
return logger.With().Str("request_id", id).Logger()
|
|
||||||
}
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
|
|
||||||
type rollingFileWriter struct {
|
type rollingFileWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
path string
|
path string
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,8 @@ package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -27,24 +23,6 @@ func resetLoggingState() {
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONLine(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
line := strings.TrimSpace(buf.String())
|
|
||||||
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
|
|
||||||
line = line[:idx]
|
|
||||||
}
|
|
||||||
if line == "" {
|
|
||||||
t.Fatalf("expected log output, got empty string")
|
|
||||||
}
|
|
||||||
|
|
||||||
var event map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
||||||
t.Fatalf("failed to unmarshal log line: %v", err)
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitJSONFormatSetsLevelAndComponent(t *testing.T) {
|
func TestInitJSONFormatSetsLevelAndComponent(t *testing.T) {
|
||||||
t.Cleanup(resetLoggingState)
|
t.Cleanup(resetLoggingState)
|
||||||
|
|
||||||
|
|
@ -118,58 +96,7 @@ func TestInitAutoFormatWithPipe(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewLoggerWithComponentAndFields(t *testing.T) {
|
func TestWithRequestID(t *testing.T) {
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
Component: "root",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New("worker", WithWriter(&buf), WithFields(map[string]interface{}{
|
|
||||||
"request": "sync",
|
|
||||||
}))
|
|
||||||
|
|
||||||
logger.Info().Msg("processing")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
|
|
||||||
if event["component"] != "worker" {
|
|
||||||
t.Fatalf("expected component worker, got %v", event["component"])
|
|
||||||
}
|
|
||||||
if event["request"] != "sync" {
|
|
||||||
t.Fatalf("expected request field, got %v", event["request"])
|
|
||||||
}
|
|
||||||
if event["level"] != "info" {
|
|
||||||
t.Fatalf("expected level info, got %v", event["level"])
|
|
||||||
}
|
|
||||||
if event["message"] != "processing" {
|
|
||||||
t.Fatalf("expected message processing, got %v", event["message"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewLoggerInheritsComponentWhenEmpty(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
Component: "core",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New("", WithWriter(&buf))
|
|
||||||
logger.Warn().Msg("warn")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
if event["component"] != "core" {
|
|
||||||
t.Fatalf("expected inherited component core, got %v", event["component"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewLoggerWithCaller(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
t.Cleanup(resetLoggingState)
|
||||||
|
|
||||||
Init(Config{
|
Init(Config{
|
||||||
|
|
@ -177,104 +104,21 @@ func TestNewLoggerWithCaller(t *testing.T) {
|
||||||
Level: "info",
|
Level: "info",
|
||||||
})
|
})
|
||||||
|
|
||||||
var buf bytes.Buffer
|
ctx, generated := WithRequestID(nil, "")
|
||||||
logger := New("svc", WithWriter(&buf), WithCaller())
|
|
||||||
logger.Error().Msg("boom")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
caller, ok := event["caller"].(string)
|
|
||||||
if !ok || !strings.Contains(caller, "logging_test.go") {
|
|
||||||
t.Fatalf("expected caller information, got %v", event["caller"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewLoggerWithCustomWriter(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New("custom", WithWriter(&buf))
|
|
||||||
logger.Info().Msg("hello")
|
|
||||||
|
|
||||||
if buf.Len() == 0 {
|
|
||||||
t.Fatal("expected output on custom writer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextHelpersWithRequestID(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ctx, generated := WithRequestID(ctx, "")
|
|
||||||
if generated == "" {
|
if generated == "" {
|
||||||
t.Fatal("expected generated request id")
|
t.Fatal("expected generated request id")
|
||||||
}
|
}
|
||||||
if got := GetRequestID(ctx); got != generated {
|
|
||||||
t.Fatalf("expected stored request id %s, got %s", generated, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New("api", WithWriter(&buf))
|
|
||||||
ctx = WithLogger(ctx, logger)
|
|
||||||
|
|
||||||
info := FromContext(ctx)
|
|
||||||
info.Info().Msg("ctx-log")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
if event["request_id"] != generated {
|
|
||||||
t.Fatalf("expected request_id %s, got %v", generated, event["request_id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContextHelpersWithExistingLogger(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "debug",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
base := New("svc", WithWriter(&buf))
|
|
||||||
ctx := WithLogger(context.Background(), base)
|
|
||||||
ctx, id := WithRequestID(ctx, "custom-123")
|
|
||||||
|
|
||||||
logger := FromContext(ctx)
|
|
||||||
logger.Debug().Msg("debug")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
if event["component"] != "svc" {
|
|
||||||
t.Fatalf("expected component svc, got %v", event["component"])
|
|
||||||
}
|
|
||||||
if event["request_id"] != "custom-123" {
|
|
||||||
t.Fatalf("expected request_id custom-123, got %v", event["request_id"])
|
|
||||||
}
|
|
||||||
if event["level"] != "debug" {
|
|
||||||
t.Fatalf("expected level debug, got %v", event["level"])
|
|
||||||
}
|
|
||||||
if id != "custom-123" {
|
|
||||||
t.Fatalf("expected returned id to match input, got %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithLoggerNilContext(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{})
|
|
||||||
|
|
||||||
ctx := WithLogger(context.Background(), New("svc", WithWriter(io.Discard)))
|
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
t.Fatal("expected non-nil context")
|
t.Fatal("expected non-nil context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx2, id := WithRequestID(nil, "custom-123")
|
||||||
|
if id != "custom-123" {
|
||||||
|
t.Fatalf("expected custom-123, got %s", id)
|
||||||
|
}
|
||||||
|
if ctx2 == nil {
|
||||||
|
t.Fatal("expected non-nil context")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithRequestIDTrimsWhitespace(t *testing.T) {
|
func TestWithRequestIDTrimsWhitespace(t *testing.T) {
|
||||||
|
|
@ -282,56 +126,10 @@ func TestWithRequestIDTrimsWhitespace(t *testing.T) {
|
||||||
|
|
||||||
Init(Config{})
|
Init(Config{})
|
||||||
|
|
||||||
ctx, id := WithRequestID(context.Background(), " ")
|
_, id := WithRequestID(nil, " ")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
t.Fatal("expected generated id for whitespace input")
|
t.Fatal("expected generated id for whitespace input")
|
||||||
}
|
}
|
||||||
if GetRequestID(ctx) != id {
|
|
||||||
t.Fatalf("expected context request id %s, got %s", id, GetRequestID(ctx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromContextWithoutRequestID(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mu.Lock()
|
|
||||||
baseLogger = zerolog.New(&buf).With().Timestamp().Logger()
|
|
||||||
baseWriter = &buf
|
|
||||||
baseComponent = ""
|
|
||||||
log.Logger = baseLogger
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
base := FromContext(context.Background())
|
|
||||||
base.Info().Msg("no-request")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
if _, ok := event["request_id"]; ok {
|
|
||||||
t.Fatalf("did not expect request_id, got %v", event["request_id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewLoggerWithoutComponentOmitsField(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
Init(Config{
|
|
||||||
Format: "json",
|
|
||||||
Level: "info",
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New("", WithWriter(&buf))
|
|
||||||
logger.Info().Msg("no-component")
|
|
||||||
|
|
||||||
event := readJSONLine(t, &buf)
|
|
||||||
if _, exists := event["component"]; exists {
|
|
||||||
t.Fatalf("did not expect component field, got %v", event["component"])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitThreadSafety(t *testing.T) {
|
func TestInitThreadSafety(t *testing.T) {
|
||||||
|
|
@ -364,91 +162,6 @@ func TestInitThreadSafety(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitFromConfigWithDefaults(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
logger, err := InitFromConfig(context.Background(), Config{
|
|
||||||
Component: "test",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if reflect.DeepEqual(logger, zerolog.Logger{}) {
|
|
||||||
t.Fatal("expected initialized logger")
|
|
||||||
}
|
|
||||||
|
|
||||||
if zerolog.GlobalLevel() != zerolog.InfoLevel {
|
|
||||||
t.Fatalf("expected default info level, got %s", zerolog.GlobalLevel())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitFromConfigWithEnvOverrides(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.Unsetenv("LOG_LEVEL")
|
|
||||||
os.Unsetenv("LOG_FORMAT")
|
|
||||||
})
|
|
||||||
|
|
||||||
os.Setenv("LOG_LEVEL", "debug")
|
|
||||||
os.Setenv("LOG_FORMAT", "json")
|
|
||||||
|
|
||||||
logger, err := InitFromConfig(context.Background(), Config{
|
|
||||||
Level: "info",
|
|
||||||
Format: "console",
|
|
||||||
Component: "test",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if reflect.DeepEqual(logger, zerolog.Logger{}) {
|
|
||||||
t.Fatal("expected initialized logger")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Env override should set debug level
|
|
||||||
if zerolog.GlobalLevel() != zerolog.DebugLevel {
|
|
||||||
t.Fatalf("expected debug level from env override, got %s", zerolog.GlobalLevel())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format should be JSON (from env)
|
|
||||||
mu.RLock()
|
|
||||||
defer mu.RUnlock()
|
|
||||||
if _, ok := baseWriter.(zerolog.ConsoleWriter); ok {
|
|
||||||
t.Fatal("expected JSON writer from env override, got console writer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitFromConfigInvalidLevel(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
_, err := InitFromConfig(context.Background(), Config{
|
|
||||||
Level: "invalid",
|
|
||||||
Format: "json",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid level")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "invalid log level") {
|
|
||||||
t.Fatalf("expected invalid level error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitFromConfigInvalidFormat(t *testing.T) {
|
|
||||||
t.Cleanup(resetLoggingState)
|
|
||||||
|
|
||||||
_, err := InitFromConfig(context.Background(), Config{
|
|
||||||
Level: "info",
|
|
||||||
Format: "invalid",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid format")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "invalid log format") {
|
|
||||||
t.Fatalf("expected invalid format error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsLevelEnabled(t *testing.T) {
|
func TestIsLevelEnabled(t *testing.T) {
|
||||||
t.Cleanup(resetLoggingState)
|
t.Cleanup(resetLoggingState)
|
||||||
|
|
||||||
|
|
@ -474,3 +187,108 @@ func TestIsLevelEnabled(t *testing.T) {
|
||||||
t.Fatal("expected debug level to be enabled after setting global level")
|
t.Fatal("expected debug level to be enabled after setting global level")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRollingFileWriter(t *testing.T) {
|
||||||
|
t.Cleanup(resetLoggingState)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
logFile := dir + "/test.log"
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Format: "json",
|
||||||
|
Level: "info",
|
||||||
|
FilePath: logFile,
|
||||||
|
MaxSizeMB: 1,
|
||||||
|
MaxAgeDays: 7,
|
||||||
|
Compress: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
Init(cfg)
|
||||||
|
|
||||||
|
// Write some log output
|
||||||
|
log.Info().Msg("test message")
|
||||||
|
|
||||||
|
// Check file exists (this confirms rolling file writer was created)
|
||||||
|
if _, err := os.Stat(logFile); os.IsNotExist(err) {
|
||||||
|
t.Fatal("expected log file to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file has content
|
||||||
|
data, err := os.ReadFile(logFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read log file: %v", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Fatal("expected log file to have content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLevelDefaults(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want zerolog.Level
|
||||||
|
}{
|
||||||
|
{"debug", zerolog.DebugLevel},
|
||||||
|
{"DEBUG", zerolog.DebugLevel},
|
||||||
|
{"info", zerolog.InfoLevel},
|
||||||
|
{"INFO", zerolog.InfoLevel},
|
||||||
|
{"warn", zerolog.WarnLevel},
|
||||||
|
{"WARN", zerolog.WarnLevel},
|
||||||
|
{"error", zerolog.ErrorLevel},
|
||||||
|
{"ERROR", zerolog.ErrorLevel},
|
||||||
|
{"unknown", zerolog.InfoLevel},
|
||||||
|
{"", zerolog.InfoLevel},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := parseLevel(tc.input)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("parseLevel(%q) = %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectWriter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
format string
|
||||||
|
isTTY bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"json", false, false},
|
||||||
|
{"console", false, false},
|
||||||
|
{"auto", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
w := selectWriter(tc.format)
|
||||||
|
if w == nil {
|
||||||
|
t.Errorf("selectWriter(%q) returned nil", tc.format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that the logging package doesn't panic under concurrent use
|
||||||
|
func TestConcurrentLogging(t *testing.T) {
|
||||||
|
t.Cleanup(resetLoggingState)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mu.Lock()
|
||||||
|
baseWriter = &buf
|
||||||
|
baseLogger = zerolog.New(&buf).With().Timestamp().Logger()
|
||||||
|
log.Logger = baseLogger
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
log.Info().Int("iteration", n).Msg("concurrent log")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
t.Fatal("expected log output from concurrent logging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||||
|
|
@ -50,87 +48,6 @@ var (
|
||||||
[]string{"type"},
|
[]string{"type"},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification metrics
|
|
||||||
NotificationsSentTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_notifications_sent_total",
|
|
||||||
Help: "Total number of notifications sent by method and status",
|
|
||||||
},
|
|
||||||
[]string{"method", "status"}, // method: email, webhook, apprise; status: success, failure
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationQueueDepth = promauto.NewGaugeVec(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "pulse_notification_queue_depth",
|
|
||||||
Help: "Number of notifications in queue by status",
|
|
||||||
},
|
|
||||||
[]string{"status"}, // pending, sending, dlq
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationDeliveryDuration = promauto.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Name: "pulse_notification_delivery_duration_seconds",
|
|
||||||
Help: "Time to deliver notifications",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
},
|
|
||||||
[]string{"method"},
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationRetriesTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_notification_retries_total",
|
|
||||||
Help: "Total number of notification retry attempts",
|
|
||||||
},
|
|
||||||
[]string{"method"},
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationDLQTotal = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_notification_dlq_total",
|
|
||||||
Help: "Total number of notifications moved to dead letter queue",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Alert grouping metrics
|
|
||||||
NotificationsGroupedTotal = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_notifications_grouped_total",
|
|
||||||
Help: "Total number of grouped notifications sent",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
AlertsGroupedCount = promauto.NewHistogram(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Name: "pulse_alerts_grouped_count",
|
|
||||||
Help: "Number of alerts in grouped notifications",
|
|
||||||
Buckets: []float64{1, 2, 5, 10, 20, 50, 100},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Escalation metrics
|
|
||||||
AlertEscalationsTotal = promauto.NewCounterVec(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_alert_escalations_total",
|
|
||||||
Help: "Total number of alert escalations by level",
|
|
||||||
},
|
|
||||||
[]string{"level"},
|
|
||||||
)
|
|
||||||
|
|
||||||
// History metrics
|
|
||||||
AlertHistorySize = promauto.NewGauge(
|
|
||||||
prometheus.GaugeOpts{
|
|
||||||
Name: "pulse_alert_history_size",
|
|
||||||
Help: "Number of alerts in history",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
AlertHistorySaveErrors = promauto.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Name: "pulse_alert_history_save_errors_total",
|
|
||||||
Help: "Total number of alert history save failures",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Suppression and quiet hours metrics
|
// Suppression and quiet hours metrics
|
||||||
AlertsSuppressedTotal = promauto.NewCounterVec(
|
AlertsSuppressedTotal = promauto.NewCounterVec(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
|
|
@ -169,52 +86,7 @@ func RecordAlertAcknowledged() {
|
||||||
AlertsAcknowledgedTotal.Inc()
|
AlertsAcknowledgedTotal.Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordNotificationSent records a notification delivery attempt
|
|
||||||
func RecordNotificationSent(method string, success bool) {
|
|
||||||
status := "success"
|
|
||||||
if !success {
|
|
||||||
status = "failure"
|
|
||||||
}
|
|
||||||
NotificationsSentTotal.WithLabelValues(method, status).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordNotificationRetry records a notification retry
|
|
||||||
func RecordNotificationRetry(method string) {
|
|
||||||
NotificationRetriesTotal.WithLabelValues(method).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordNotificationDLQ records a notification moved to DLQ
|
|
||||||
func RecordNotificationDLQ() {
|
|
||||||
NotificationDLQTotal.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordGroupedNotification records a grouped notification
|
|
||||||
func RecordGroupedNotification(alertCount int) {
|
|
||||||
NotificationsGroupedTotal.Inc()
|
|
||||||
AlertsGroupedCount.Observe(float64(alertCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAlertEscalation records an alert escalation
|
|
||||||
func RecordAlertEscalation(level int) {
|
|
||||||
AlertEscalationsTotal.WithLabelValues(fmt.Sprintf("%d", level)).Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAlertSuppressed records a suppressed alert
|
// RecordAlertSuppressed records a suppressed alert
|
||||||
func RecordAlertSuppressed(reason string) {
|
func RecordAlertSuppressed(reason string) {
|
||||||
AlertsSuppressedTotal.WithLabelValues(reason).Inc()
|
AlertsSuppressedTotal.WithLabelValues(reason).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateQueueDepth updates the notification queue depth metric
|
|
||||||
func UpdateQueueDepth(status string, count int) {
|
|
||||||
NotificationQueueDepth.WithLabelValues(status).Set(float64(count))
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateHistorySize updates the alert history size metric
|
|
||||||
func UpdateHistorySize(size int) {
|
|
||||||
AlertHistorySize.Set(float64(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordHistorySaveError records a history save failure
|
|
||||||
func RecordHistorySaveError() {
|
|
||||||
AlertHistorySaveErrors.Inc()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -38,40 +38,6 @@ func TestRecordAlertAcknowledged(t *testing.T) {
|
||||||
RecordAlertAcknowledged()
|
RecordAlertAcknowledged()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordNotificationSent_Success(t *testing.T) {
|
|
||||||
// Should not panic
|
|
||||||
RecordNotificationSent("email", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordNotificationSent_Failure(t *testing.T) {
|
|
||||||
// Should not panic
|
|
||||||
RecordNotificationSent("webhook", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordNotificationRetry(t *testing.T) {
|
|
||||||
// Should not panic
|
|
||||||
RecordNotificationRetry("email")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordNotificationDLQ(t *testing.T) {
|
|
||||||
// Should not panic
|
|
||||||
RecordNotificationDLQ()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordGroupedNotification(t *testing.T) {
|
|
||||||
// Should not panic with various counts
|
|
||||||
RecordGroupedNotification(1)
|
|
||||||
RecordGroupedNotification(5)
|
|
||||||
RecordGroupedNotification(50)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordAlertEscalation(t *testing.T) {
|
|
||||||
// Should not panic with various levels
|
|
||||||
RecordAlertEscalation(1)
|
|
||||||
RecordAlertEscalation(2)
|
|
||||||
RecordAlertEscalation(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordAlertSuppressed(t *testing.T) {
|
func TestRecordAlertSuppressed(t *testing.T) {
|
||||||
// Should not panic with various reasons
|
// Should not panic with various reasons
|
||||||
RecordAlertSuppressed("quiet_hours")
|
RecordAlertSuppressed("quiet_hours")
|
||||||
|
|
@ -79,25 +45,6 @@ func TestRecordAlertSuppressed(t *testing.T) {
|
||||||
RecordAlertSuppressed("duplicate")
|
RecordAlertSuppressed("duplicate")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateQueueDepth(t *testing.T) {
|
|
||||||
// Should not panic with various statuses and counts
|
|
||||||
UpdateQueueDepth("pending", 10)
|
|
||||||
UpdateQueueDepth("sending", 5)
|
|
||||||
UpdateQueueDepth("dlq", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateHistorySize(t *testing.T) {
|
|
||||||
// Should not panic with various sizes
|
|
||||||
UpdateHistorySize(0)
|
|
||||||
UpdateHistorySize(100)
|
|
||||||
UpdateHistorySize(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecordHistorySaveError(t *testing.T) {
|
|
||||||
// Should not panic
|
|
||||||
RecordHistorySaveError()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricVectors_NotNil(t *testing.T) {
|
func TestMetricVectors_NotNil(t *testing.T) {
|
||||||
// Verify that metric vectors are properly initialized
|
// Verify that metric vectors are properly initialized
|
||||||
if AlertsActive == nil {
|
if AlertsActive == nil {
|
||||||
|
|
@ -115,36 +62,6 @@ func TestMetricVectors_NotNil(t *testing.T) {
|
||||||
if AlertDurationSeconds == nil {
|
if AlertDurationSeconds == nil {
|
||||||
t.Error("AlertDurationSeconds should not be nil")
|
t.Error("AlertDurationSeconds should not be nil")
|
||||||
}
|
}
|
||||||
if NotificationsSentTotal == nil {
|
|
||||||
t.Error("NotificationsSentTotal should not be nil")
|
|
||||||
}
|
|
||||||
if NotificationQueueDepth == nil {
|
|
||||||
t.Error("NotificationQueueDepth should not be nil")
|
|
||||||
}
|
|
||||||
if NotificationDeliveryDuration == nil {
|
|
||||||
t.Error("NotificationDeliveryDuration should not be nil")
|
|
||||||
}
|
|
||||||
if NotificationRetriesTotal == nil {
|
|
||||||
t.Error("NotificationRetriesTotal should not be nil")
|
|
||||||
}
|
|
||||||
if NotificationDLQTotal == nil {
|
|
||||||
t.Error("NotificationDLQTotal should not be nil")
|
|
||||||
}
|
|
||||||
if NotificationsGroupedTotal == nil {
|
|
||||||
t.Error("NotificationsGroupedTotal should not be nil")
|
|
||||||
}
|
|
||||||
if AlertsGroupedCount == nil {
|
|
||||||
t.Error("AlertsGroupedCount should not be nil")
|
|
||||||
}
|
|
||||||
if AlertEscalationsTotal == nil {
|
|
||||||
t.Error("AlertEscalationsTotal should not be nil")
|
|
||||||
}
|
|
||||||
if AlertHistorySize == nil {
|
|
||||||
t.Error("AlertHistorySize should not be nil")
|
|
||||||
}
|
|
||||||
if AlertHistorySaveErrors == nil {
|
|
||||||
t.Error("AlertHistorySaveErrors should not be nil")
|
|
||||||
}
|
|
||||||
if AlertsSuppressedTotal == nil {
|
if AlertsSuppressedTotal == nil {
|
||||||
t.Error("AlertsSuppressedTotal should not be nil")
|
t.Error("AlertsSuppressedTotal should not be nil")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue