Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deploy/helm/solidping/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ PostgreSQL create a Secret with the connection string under `url` and set
The JWT signing key is generated on first install and kept across upgrades.
Set `auth.existingSecret` (key `jwt-secret`) to manage it yourself.

Logs are JSON, one object per line (`SP_LOG_FORMAT=json`). Set
`extraEnv.SP_LOG_FORMAT` to change it.

First login is `admin@solidping.io` / `solidpass`. Change it immediately.
5 changes: 5 additions & 0 deletions deploy/helm/solidping/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ spec:
name: {{ required "database.existingSecret is required with database.type=postgres" .Values.database.existingSecret }}
key: url
{{- end }}
{{- /* One JSON object per line for the cluster's log collector. extraEnv.SP_LOG_FORMAT replaces it. */}}
{{- if not (hasKey .Values.extraEnv "SP_LOG_FORMAT") }}
- name: SP_LOG_FORMAT
value: json
{{- end }}
{{- range $k, $v := .Values.extraEnv }}
- name: {{ $k }}
value: {{ $v | quote }}
Expand Down
1 change: 1 addition & 0 deletions deploy/helm/solidping/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ persistence:
existingClaim: ""

# Extra SP_* environment variables, e.g. { SP_SERVER_COMPRESSION: "true" }.
# The chart sets SP_LOG_FORMAT=json; an SP_LOG_FORMAT entry here replaces it.
extraEnv: {}

# ICMP (ping) checks need a raw socket.
Expand Down
3 changes: 2 additions & 1 deletion server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ This file provides backend-specific guidance for the SolidPing monitoring system
The measurement core (median/p95/spread and the "not significant" rule) is
pure, unit-tested code in `internal/membench`; `internal/meminfo` holds the
`/proc` + cgroup parsers behind `/api/mgmt/memory`.
- **Set log level**: `LOG_LEVEL=debug ./solidping serve` (valid values: debug, info, warn, error)
- **Set log level**: `SP_LOG_LEVEL=debug ./solidping serve` (valid values: debug, info, warn, error). `LOG_LEVEL` is still accepted as a fallback when `SP_LOG_LEVEL` is unset (`config.LogLevelFromEnv`).
- **Log format**: `SP_LOG_FORMAT` = `auto` (default), `text`, `json` or `pretty`. `auto` is resolved once at logger setup in `internal/logsetup`: json on Kubernetes, pretty on a terminal or in a container, text otherwise. Colors follow `NO_COLOR` / `FORCE_COLOR`, then whether the writer itself is a TTY or a container.

## Architecture Overview

Expand Down
2 changes: 1 addition & 1 deletion server/cmd_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func runMCPStdioHelper(raw string) int {
return 2
}

setupLogger(config.ParseLogLevel(os.Getenv("LOG_LEVEL")), config.ParseLogFormat(os.Getenv("SP_LOG_FORMAT")))
setupLogger(config.LogLevelFromEnv(os.Getenv), config.ParseLogFormat(os.Getenv("SP_LOG_FORMAT")))

return run(context.Background(), buildRootCommand(), append([]string{"solidping"}, args...))
}
Expand Down
1 change: 1 addition & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ require (
github.com/lib/pq v1.12.3
github.com/likexian/whois v1.15.7
github.com/likexian/whois-parser v1.24.21
github.com/lmittmann/tint v1.2.1
github.com/mattn/go-sqlite3 v1.14.52
github.com/microsoft/go-mssqldb v1.11.2
github.com/miekg/dns v1.1.73
Expand Down
2 changes: 2 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
github.com/lmittmann/tint v1.2.1 h1:zOadah+f+nSm3jZM044jOsqh0+vSfgG/d9Ota9a/TLE=
github.com/lmittmann/tint v1.2.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 h1:GxMuVb9tJajC1QpbQwYNY1ZAo1EIE8I+UclBjOfjz/M=
Expand Down
38 changes: 26 additions & 12 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ type Config struct {
AI AIConfig `koanf:"ai"`
RunMode string `koanf:"runmode"` // "test" for test mode, empty for normal mode
UserAgent string `koanf:"useragent"` // Identity string for protocol checks (SP_USERAGENT)
LogLevel slog.Level `koanf:"-"` // Logging level (parsed from LOG_LEVEL env var)
LogLevel slog.Level `koanf:"-"` // Logging level (SP_LOG_LEVEL, LOG_LEVEL as fallback)
LogFormat LogFormat `koanf:"-"` // Logging output format (parsed from SP_LOG_FORMAT env var)
}

Expand Down Expand Up @@ -2298,8 +2298,7 @@ func Load() (*Config, error) {
applyDBSlowQueryEnv(&cfg.Database)
applyMigrationGuardModeEnv(&cfg.Database)

// Parse LOG_LEVEL environment variable
cfg.LogLevel = ParseLogLevel(os.Getenv("SP_LOG_LEVEL"))
cfg.LogLevel = LogLevelFromEnv(os.Getenv)
cfg.LogFormat = ParseLogFormat(os.Getenv("SP_LOG_FORMAT"))

// App / GitHub: SP_APP_GITHUB_ISSUES_TOKEN takes precedence over the
Expand Down Expand Up @@ -3863,27 +3862,42 @@ func ParseLogLevel(level string) slog.Level {
}
}

// LogLevelFromEnv reads the log level from the environment: SP_LOG_LEVEL wins,
// LOG_LEVEL is the fallback. main (before the configuration loads) and Load
// both go through it, so LOG_LEVEL set alone is no longer reset to info once
// the configuration is loaded.
func LogLevelFromEnv(getenv func(key string) string) slog.Level {
if level := strings.TrimSpace(getenv("SP_LOG_LEVEL")); level != "" {
return ParseLogLevel(level)
}

return ParseLogLevel(getenv("LOG_LEVEL"))
}

// LogFormat selects how log records are rendered on stdout.
type LogFormat string

const (
// LogFormatText is Go's key=value logfmt-style handler. The default: it
// stays readable with no tooling, which is what a self-hosted operator
// tailing a container gets without opting in to anything.
// LogFormatAuto is the default: the concrete format is picked from the
// environment when the logger is built (json on Kubernetes, pretty on a
// terminal or in a container, text otherwise). See internal/logsetup.
LogFormatAuto LogFormat = "auto"
// LogFormatText is Go's key=value logfmt-style handler. It stays readable
// with no tooling.
LogFormatText LogFormat = "text"
// LogFormatJSON emits one JSON object per record. Pick this when a log
// collector parses stdout — attributes become queryable fields instead of
// being buried in a formatted message.
LogFormatJSON LogFormat = "json"
// LogFormatPretty is the colorized, human-first handler. For interactive
// development; it is not machine-parseable.
// LogFormatPretty is the colorized, human-first handler. For terminals and
// `docker logs`; it is not machine-parseable.
LogFormatPretty LogFormat = "pretty"
)

// ParseLogFormat parses a log format string into a LogFormat.
// Valid values: text, json, pretty (case-insensitive).
// Returns LogFormatText if the value is empty or unrecognized — an unreadable
// typo must not cost the operator their logs.
// Valid values: auto, text, json, pretty (case-insensitive).
// Returns LogFormatAuto if the value is empty or unrecognized — a typo must
// not cost the operator their logs.
func ParseLogFormat(format string) LogFormat {
switch strings.ToLower(strings.TrimSpace(format)) {
case "json":
Expand All @@ -3893,6 +3907,6 @@ func ParseLogFormat(format string) LogFormat {
case "text", "logfmt":
return LogFormatText
default:
return LogFormatText
return LogFormatAuto
}
}
68 changes: 60 additions & 8 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,14 +333,19 @@ func TestParseLogFormat(t *testing.T) {
expected: LogFormatPretty,
},
{
name: "empty string defaults to text",
name: "auto",
input: "Auto",
expected: LogFormatAuto,
},
{
name: "empty string defaults to auto",
input: "",
expected: LogFormatText,
expected: LogFormatAuto,
},
{
name: "invalid value defaults to text",
name: "invalid value defaults to auto",
input: "yaml",
expected: LogFormatText,
expected: LogFormatAuto,
},
{
name: "whitespace trimmed",
Expand All @@ -353,10 +358,57 @@ func TestParseLogFormat(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

result := ParseLogFormat(testCase.input)
if result != testCase.expected {
t.Errorf("ParseLogFormat(%q) = %v, want %v", testCase.input, result, testCase.expected)
}
require.Equal(t, testCase.expected, ParseLogFormat(testCase.input))
})
}
}

func TestLogLevelFromEnv(t *testing.T) {
t.Parallel()

tests := []struct {
name string
env map[string]string
expected slog.Level
}{
{
name: "nothing set defaults to info",
env: map[string]string{},
expected: slog.LevelInfo,
},
{
name: "SP_LOG_LEVEL alone",
env: map[string]string{"SP_LOG_LEVEL": "debug"},
expected: slog.LevelDebug,
},
{
name: "LOG_LEVEL alone is the fallback",
env: map[string]string{"LOG_LEVEL": "warn"},
expected: slog.LevelWarn,
},
{
name: "SP_LOG_LEVEL wins over LOG_LEVEL",
env: map[string]string{"SP_LOG_LEVEL": "error", "LOG_LEVEL": "debug"},
expected: slog.LevelError,
},
{
name: "blank SP_LOG_LEVEL falls back to LOG_LEVEL",
env: map[string]string{"SP_LOG_LEVEL": " ", "LOG_LEVEL": "warn"},
expected: slog.LevelWarn,
},
{
name: "invalid SP_LOG_LEVEL is info, not LOG_LEVEL",
env: map[string]string{"SP_LOG_LEVEL": "loud", "LOG_LEVEL": "warn"},
expected: slog.LevelInfo,
},
}

for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()

getenv := func(key string) string { return testCase.env[key] }
require.Equal(t, testCase.expected, LogLevelFromEnv(getenv))
})
}
}
Expand Down
8 changes: 8 additions & 0 deletions server/internal/logsetup/ansi_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !windows

package logsetup

import "io"

// enableANSI is a no-op outside Windows: terminals render ANSI natively.
func enableANSI(io.Writer) {}
27 changes: 27 additions & 0 deletions server/internal/logsetup/ansi_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build windows

package logsetup

import (
"io"

"golang.org/x/sys/windows"
)

// enableANSI turns on virtual terminal processing on a Windows console, so it
// renders the ANSI color sequences instead of printing them.
func enableANSI(out io.Writer) {
file, ok := out.(fdWriter)
if !ok {
return
}

handle := windows.Handle(file.Fd())

var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err != nil {
return
}

_ = windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
}
Loading
Loading