Skip to content

Commit 9fabea4

Browse files
aksOpsclaude
andcommitted
feat(log): log.format=json|text with emoji-strip production handler
New log.format config key (default "text"; DOCSIQ_LOG_FORMAT env). Precedence is --log-format > env > config > default. The json handler is wrapped in obs.NewProductionHandler, which strips a leading emoji from slog Record.Message so log aggregators do not have to special- case multi-byte sequences. The text handler keeps emoji for human readers. Adds config-level defaults + env binding and three load- level tests covering default, YAML, and env-var precedence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f190ae commit 9fabea4

6 files changed

Lines changed: 313 additions & 14 deletions

File tree

cmd/logformat_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package cmd
33
import (
44
"bytes"
55
"encoding/json"
6+
"io"
67
"log/slog"
8+
"os"
9+
"path/filepath"
710
"testing"
811
)
912

@@ -28,3 +31,73 @@ func TestLogFormatJSON(t *testing.T) {
2831
t.Errorf("k = %v, want v", decoded["k"])
2932
}
3033
}
34+
35+
// TestBuildLogHandler_JSONStripsEmoji confirms buildLogHandler returns
36+
// a handler chain that strips emoji from the message when format=json.
37+
// buildLogHandler writes to os.Stderr so we redirect it through a pipe
38+
// for the test (NOT parallel — shares global os.Stderr).
39+
func TestBuildLogHandler_JSONStripsEmoji(t *testing.T) {
40+
origStderr := os.Stderr
41+
r, w, err := os.Pipe()
42+
if err != nil {
43+
t.Fatalf("pipe: %v", err)
44+
}
45+
os.Stderr = w
46+
t.Cleanup(func() { os.Stderr = origStderr })
47+
48+
h := buildLogHandler(slog.LevelInfo, "json")
49+
slog.New(h).Info("✅ ready", "k", "v")
50+
_ = w.Close()
51+
52+
out, err := io.ReadAll(r)
53+
if err != nil {
54+
t.Fatalf("read: %v", err)
55+
}
56+
57+
var rec map[string]any
58+
if err := json.Unmarshal(bytes.TrimSpace(out), &rec); err != nil {
59+
t.Fatalf("not JSON: %v — raw=%q", err, out)
60+
}
61+
msg, _ := rec["msg"].(string)
62+
if msg != "ready" {
63+
t.Errorf("msg=%q want 'ready' (emoji stripped)", msg)
64+
}
65+
}
66+
67+
// TestInitConfig_LogFormatFromConfigFile asserts the config file is
68+
// consulted when neither --log-format nor DOCSIQ_LOG_FORMAT is set.
69+
// NOT parallel — mutates env + HOME + package-level flags.
70+
func TestInitConfig_LogFormatFromConfigFile(t *testing.T) {
71+
origHome := os.Getenv("HOME")
72+
origLogFormat := os.Getenv("DOCSIQ_LOG_FORMAT")
73+
t.Cleanup(func() {
74+
logLevel = "info"
75+
logFormat = ""
76+
cfgFile = ""
77+
cfg = nil
78+
os.Setenv("HOME", origHome)
79+
if origLogFormat != "" {
80+
os.Setenv("DOCSIQ_LOG_FORMAT", origLogFormat)
81+
}
82+
})
83+
84+
dir := t.TempDir()
85+
os.Setenv("HOME", dir)
86+
os.Unsetenv("DOCSIQ_LOG_FORMAT")
87+
88+
yaml := filepath.Join(dir, "config.yaml")
89+
if err := os.WriteFile(yaml, []byte("log:\n format: json\n"), 0o600); err != nil {
90+
t.Fatal(err)
91+
}
92+
cfgFile = yaml
93+
logFormat = ""
94+
95+
initConfig()
96+
97+
if cfg == nil {
98+
t.Fatal("initConfig produced nil cfg")
99+
}
100+
if cfg.Log.Format != "json" {
101+
t.Errorf("cfg.Log.Format=%q want json", cfg.Log.Format)
102+
}
103+
}

cmd/root.go

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/RandomCodeSpace/docsiq/internal/config"
10+
"github.com/RandomCodeSpace/docsiq/internal/obs"
1011
"github.com/spf13/cobra"
1112
)
1213

@@ -41,7 +42,7 @@ func init() {
4142
}
4243

4344
func initConfig() {
44-
// Set up structured logger
45+
// Set up structured logger. Level comes from --log-level only.
4546
var level slog.Level
4647
switch logLevel {
4748
case "debug":
@@ -53,32 +54,51 @@ func initConfig() {
5354
default:
5455
level = slog.LevelInfo
5556
}
56-
// Log format: --log-format wins, else DOCSIQ_LOG_FORMAT, else "text".
57+
58+
// Format resolution order (highest wins):
59+
// 1. --log-format flag
60+
// 2. DOCSIQ_LOG_FORMAT env var
61+
// 3. config file log.format
62+
// 4. default "text"
63+
// (3) requires config.Load() to have run; install a temporary
64+
// handler first so config-load errors land somewhere, then
65+
// upgrade once the final format is known.
5766
format := strings.ToLower(strings.TrimSpace(logFormat))
5867
if format == "" {
5968
format = strings.ToLower(strings.TrimSpace(os.Getenv("DOCSIQ_LOG_FORMAT")))
6069
}
61-
handlerOpts := &slog.HandlerOptions{Level: level}
62-
var handler slog.Handler
63-
switch format {
64-
case "json":
65-
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
66-
default:
67-
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
68-
}
69-
slog.SetDefault(slog.New(handler))
70+
71+
slog.SetDefault(slog.New(buildLogHandler(level, format)))
7072

7173
var err error
7274
cfg, err = config.Load(cfgFile)
7375
if err != nil {
7476
slog.Error("❌ config error", "err", err)
7577
os.Exit(1)
7678
}
77-
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
79+
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
7880
slog.Error("❌ failed to create data directory", "path", cfg.DataDir, "err", err)
7981
os.Exit(1)
8082
}
81-
}
82-
8383

84+
// If neither flag nor env specified format, use the value from
85+
// the loaded config (falls back to default "text").
86+
if format == "" && cfg.Log.Format != "" {
87+
format = strings.ToLower(strings.TrimSpace(cfg.Log.Format))
88+
slog.SetDefault(slog.New(buildLogHandler(level, format)))
89+
}
90+
}
8491

92+
// buildLogHandler assembles the slog handler chain. For "json" format
93+
// we wrap the JSON handler in obs.NewProductionHandler to strip emoji
94+
// prefixes from the message field (keeping them is harmless but noisy
95+
// for log aggregators). "text" keeps emoji for human readability.
96+
func buildLogHandler(level slog.Level, format string) slog.Handler {
97+
opts := &slog.HandlerOptions{Level: level}
98+
switch format {
99+
case "json":
100+
return obs.NewProductionHandler(slog.NewJSONHandler(os.Stderr, opts))
101+
default:
102+
return slog.NewTextHandler(os.Stderr, opts)
103+
}
104+
}

internal/config/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Config struct {
1717
Indexing IndexingConfig `mapstructure:"indexing"`
1818
Community CommunityConfig `mapstructure:"community"`
1919
Server ServerConfig `mapstructure:"server"`
20+
Log LogConfig `mapstructure:"log"`
2021

2122
// Phase-5: per-project LLM overrides. Keyed by project slug.
2223
// When a slug is missing from the map, callers fall back to the
@@ -25,6 +26,16 @@ type Config struct {
2526
LLMOverrides map[string]LLMConfig `mapstructure:"llm_overrides"`
2627
}
2728

29+
// LogConfig controls structured-log emission format. Lowest-priority
30+
// source of truth — `--log-format` flag and `DOCSIQ_LOG_FORMAT` env
31+
// var both outrank this value in cmd/root.go.
32+
type LogConfig struct {
33+
// Format chooses the slog handler. "text" (default) emits a
34+
// human-readable single-line format with emoji prefixes; "json"
35+
// strips emoji and emits machine-parseable JSON objects.
36+
Format string `mapstructure:"format"`
37+
}
38+
2839
// LLMConfigForProject returns the override for slug if present, otherwise
2940
// the root LLM config. A missing or empty slug yields the root config.
3041
// The Provider field is treated as the presence sentinel — a YAML block
@@ -219,6 +230,9 @@ func Load(cfgFile string) (*Config, error) {
219230
v.SetDefault("server.workq_depth", 64)
220231
v.SetDefault("server.hsts_enabled", false)
221232

233+
// Log format: "text" for human dev output, "json" for production.
234+
v.SetDefault("log.format", "text")
235+
222236
// Config file search paths. Only ~/.docsiq and CWD are consulted.
223237
newCfgDir := filepath.Join(home, ".docsiq")
224238
if cfgFile != "" {
@@ -244,6 +258,7 @@ func Load(cfgFile string) (*Config, error) {
244258
_ = v.BindEnv("server.workq_workers", "DOCSIQ_SERVER_WORKQ_WORKERS")
245259
_ = v.BindEnv("server.workq_depth", "DOCSIQ_SERVER_WORKQ_DEPTH")
246260
_ = v.BindEnv("server.hsts_enabled", "DOCSIQ_SERVER_HSTS_ENABLED")
261+
_ = v.BindEnv("log.format", "DOCSIQ_LOG_FORMAT")
247262

248263
if err := v.ReadInConfig(); err != nil {
249264
if _, ok := err.(viper.ConfigFileNotFoundError); ok {

internal/config/config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,3 +670,52 @@ func captureSlog(t *testing.T) *syncBuffer {
670670
t.Cleanup(func() { slog.SetDefault(prev) })
671671
return buf
672672
}
673+
674+
func TestLoad_LogFormatDefaultText(t *testing.T) {
675+
// NOT parallel — mutates env + HOME.
676+
dir := t.TempDir()
677+
isolateEnv(t, dir)
678+
// Empty cfgFile → Load searches ~/.docsiq (empty) + cwd, falls
679+
// through to defaults.
680+
cfg, err := Load("")
681+
if err != nil {
682+
t.Fatalf("Load: %v", err)
683+
}
684+
if cfg.Log.Format != "text" {
685+
t.Errorf("default Log.Format=%q want text", cfg.Log.Format)
686+
}
687+
}
688+
689+
func TestLoad_LogFormatFromYAML(t *testing.T) {
690+
// NOT parallel — mutates env + HOME.
691+
dir := t.TempDir()
692+
isolateEnv(t, dir)
693+
f := filepath.Join(dir, "config.yaml")
694+
if err := os.WriteFile(f, []byte("log:\n format: json\n"), 0o600); err != nil {
695+
t.Fatal(err)
696+
}
697+
cfg, err := Load(f)
698+
if err != nil {
699+
t.Fatalf("Load: %v", err)
700+
}
701+
if cfg.Log.Format != "json" {
702+
t.Errorf("Log.Format=%q want json", cfg.Log.Format)
703+
}
704+
}
705+
706+
func TestLoad_LogFormatFromEnv(t *testing.T) {
707+
// NOT parallel — mutates env + HOME.
708+
dir := t.TempDir()
709+
isolateEnv(t, dir)
710+
if err := os.Setenv("DOCSIQ_LOG_FORMAT", "json"); err != nil {
711+
t.Fatal(err)
712+
}
713+
defer os.Unsetenv("DOCSIQ_LOG_FORMAT")
714+
cfg, err := Load("")
715+
if err != nil {
716+
t.Fatalf("Load: %v", err)
717+
}
718+
if cfg.Log.Format != "json" {
719+
t.Errorf("env Log.Format=%q want json", cfg.Log.Format)
720+
}
721+
}

internal/obs/slogfmt.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package obs
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"strings"
7+
"unicode/utf8"
8+
)
9+
10+
// NewProductionHandler wraps an inner slog.Handler and strips a
11+
// leading emoji + trailing space from each record's Message. docsiq
12+
// uses emoji prefixes (OK KO WARN etc.) as visual cues in dev text
13+
// format; in JSON these collide with log-aggregator indexing
14+
// (Elasticsearch tokeniser, fluentd grep rules) and obscure the actual
15+
// message string. The handler mutates only Message — attrs pass
16+
// through.
17+
func NewProductionHandler(inner slog.Handler) slog.Handler {
18+
return &prodHandler{inner: inner}
19+
}
20+
21+
type prodHandler struct{ inner slog.Handler }
22+
23+
func (h *prodHandler) Enabled(ctx context.Context, lvl slog.Level) bool {
24+
return h.inner.Enabled(ctx, lvl)
25+
}
26+
27+
func (h *prodHandler) Handle(ctx context.Context, r slog.Record) error {
28+
r.Message = stripLeadingEmoji(r.Message)
29+
return h.inner.Handle(ctx, r)
30+
}
31+
32+
func (h *prodHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
33+
return &prodHandler{inner: h.inner.WithAttrs(attrs)}
34+
}
35+
36+
func (h *prodHandler) WithGroup(name string) slog.Handler {
37+
return &prodHandler{inner: h.inner.WithGroup(name)}
38+
}
39+
40+
// stripLeadingEmoji removes the first rune from msg if it is in a
41+
// Unicode emoji-like range, plus any immediately-following whitespace.
42+
// Also strips a VS16 variation selector (U+FE0F) that often follows
43+
// warning signs etc. We intentionally do NOT use a dependency like
44+
// mattn/go-emoji; docsiq ships under air-gap rules (see build.md).
45+
func stripLeadingEmoji(msg string) string {
46+
if msg == "" {
47+
return msg
48+
}
49+
r, size := utf8.DecodeRuneInString(msg)
50+
if r == utf8.RuneError {
51+
return msg
52+
}
53+
if !isEmojiLike(r) {
54+
return msg
55+
}
56+
rest := msg[size:]
57+
rest = strings.TrimLeft(rest, " \t")
58+
if len(rest) > 0 {
59+
r2, size2 := utf8.DecodeRuneInString(rest)
60+
if r2 == 0xFE0F {
61+
rest = strings.TrimLeft(rest[size2:], " \t")
62+
}
63+
}
64+
return rest
65+
}
66+
67+
// isEmojiLike is a conservative test for the emoji-range runes that
68+
// appear in docsiq log messages today. Covers BMP symbols
69+
// (U+2600-U+27BF), miscellaneous pictographs (U+1F300-U+1F6FF), and
70+
// supplemental symbols (U+1F900-U+1F9FF).
71+
func isEmojiLike(r rune) bool {
72+
switch {
73+
case r >= 0x2600 && r <= 0x27BF:
74+
return true
75+
case r >= 0x1F300 && r <= 0x1F6FF:
76+
return true
77+
case r >= 0x1F900 && r <= 0x1F9FF:
78+
return true
79+
}
80+
return false
81+
}

0 commit comments

Comments
 (0)