Skip to content
Merged
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X github.com/RandomCodeSpace/docsiq/cmd.Version=$(VERSION) \
-X github.com/RandomCodeSpace/docsiq/cmd.Commit=$(COMMIT) \
-X github.com/RandomCodeSpace/docsiq/cmd.Date=$(DATE)
LDFLAGS := -X github.com/RandomCodeSpace/docsiq/internal/buildinfo.Version=$(VERSION) \
-X github.com/RandomCodeSpace/docsiq/internal/buildinfo.Commit=$(COMMIT) \
-X github.com/RandomCodeSpace/docsiq/internal/buildinfo.Date=$(DATE)

ui-install:
cd ui && npm install
Expand Down
73 changes: 73 additions & 0 deletions cmd/logformat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package cmd
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
)

Expand All @@ -28,3 +31,73 @@ func TestLogFormatJSON(t *testing.T) {
t.Errorf("k = %v, want v", decoded["k"])
}
}

// TestBuildLogHandler_JSONStripsEmoji confirms buildLogHandler returns
// a handler chain that strips emoji from the message when format=json.
// buildLogHandler writes to os.Stderr so we redirect it through a pipe
// for the test (NOT parallel — shares global os.Stderr).
func TestBuildLogHandler_JSONStripsEmoji(t *testing.T) {
origStderr := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stderr = w
t.Cleanup(func() { os.Stderr = origStderr })

h := buildLogHandler(slog.LevelInfo, "json")
slog.New(h).Info("✅ ready", "k", "v")
_ = w.Close()

out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}

var rec map[string]any
if err := json.Unmarshal(bytes.TrimSpace(out), &rec); err != nil {
t.Fatalf("not JSON: %v — raw=%q", err, out)
}
msg, _ := rec["msg"].(string)
if msg != "ready" {
t.Errorf("msg=%q want 'ready' (emoji stripped)", msg)
}
}

// TestInitConfig_LogFormatFromConfigFile asserts the config file is
// consulted when neither --log-format nor DOCSIQ_LOG_FORMAT is set.
// NOT parallel — mutates env + HOME + package-level flags.
func TestInitConfig_LogFormatFromConfigFile(t *testing.T) {
origHome := os.Getenv("HOME")
origLogFormat := os.Getenv("DOCSIQ_LOG_FORMAT")
t.Cleanup(func() {
logLevel = "info"
logFormat = ""
cfgFile = ""
cfg = nil
os.Setenv("HOME", origHome)
if origLogFormat != "" {
os.Setenv("DOCSIQ_LOG_FORMAT", origLogFormat)
}
})

dir := t.TempDir()
os.Setenv("HOME", dir)
os.Unsetenv("DOCSIQ_LOG_FORMAT")

yaml := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(yaml, []byte("log:\n format: json\n"), 0o600); err != nil {
t.Fatal(err)
}
cfgFile = yaml
logFormat = ""

initConfig()

if cfg == nil {
t.Fatal("initConfig produced nil cfg")
}
if cfg.Log.Format != "json" {
t.Errorf("cfg.Log.Format=%q want json", cfg.Log.Format)
}
}
48 changes: 34 additions & 14 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/RandomCodeSpace/docsiq/internal/config"
"github.com/RandomCodeSpace/docsiq/internal/obs"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -41,7 +42,7 @@ func init() {
}

func initConfig() {
// Set up structured logger
// Set up structured logger. Level comes from --log-level only.
var level slog.Level
switch logLevel {
case "debug":
Expand All @@ -53,32 +54,51 @@ func initConfig() {
default:
level = slog.LevelInfo
}
// Log format: --log-format wins, else DOCSIQ_LOG_FORMAT, else "text".

// Format resolution order (highest wins):
// 1. --log-format flag
// 2. DOCSIQ_LOG_FORMAT env var
// 3. config file log.format
// 4. default "text"
// (3) requires config.Load() to have run; install a temporary
// handler first so config-load errors land somewhere, then
// upgrade once the final format is known.
format := strings.ToLower(strings.TrimSpace(logFormat))
if format == "" {
format = strings.ToLower(strings.TrimSpace(os.Getenv("DOCSIQ_LOG_FORMAT")))
}
handlerOpts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
switch format {
case "json":
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
default:
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
}
slog.SetDefault(slog.New(handler))

slog.SetDefault(slog.New(buildLogHandler(level, format)))

var err error
cfg, err = config.Load(cfgFile)
if err != nil {
slog.Error("❌ config error", "err", err)
os.Exit(1)
}
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
slog.Error("❌ failed to create data directory", "path", cfg.DataDir, "err", err)
os.Exit(1)
}
}


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

// buildLogHandler assembles the slog handler chain. For "json" format
// we wrap the JSON handler in obs.NewProductionHandler to strip emoji
// prefixes from the message field (keeping them is harmless but noisy
// for log aggregators). "text" keeps emoji for human readability.
func buildLogHandler(level slog.Level, format string) slog.Handler {
opts := &slog.HandlerOptions{Level: level}
switch format {
case "json":
return obs.NewProductionHandler(slog.NewJSONHandler(os.Stderr, opts))
default:
return slog.NewTextHandler(os.Stderr, opts)
}
}
15 changes: 15 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import (
"time"

"github.com/RandomCodeSpace/docsiq/internal/api"
"github.com/RandomCodeSpace/docsiq/internal/buildinfo"
"github.com/RandomCodeSpace/docsiq/internal/config"
"github.com/RandomCodeSpace/docsiq/internal/embedder"
"github.com/RandomCodeSpace/docsiq/internal/llm"
"github.com/RandomCodeSpace/docsiq/internal/obs"
"github.com/RandomCodeSpace/docsiq/internal/project"
"github.com/RandomCodeSpace/docsiq/internal/sqlitevec"
"github.com/RandomCodeSpace/docsiq/internal/vectorindex"
Expand Down Expand Up @@ -154,6 +156,19 @@ var serveCmd = &cobra.Command{
}
pool := workq.New(workq.Config{Workers: workers, QueueDepth: depth})

// Observability — initialise the Prometheus registry once per
// process and bind the workq stats provider so the /metrics
// scrape can read live queue depth + rejection count.
obs.Init()
obs.Workq.BindStatsProvider(func() obs.WorkqStats {
s := pool.Stats()
return obs.WorkqStats{Depth: s.Depth, Rejected: s.Rejected}
})
{
info := buildinfo.Resolve(false)
api.SetBuildInfo(info.Version, info.Commit)
}

router := api.NewRouter(prov, emb, cfg, registry,
api.WithProjectStores(stores),
api.WithVectorIndexes(vecIndexes),
Expand Down
110 changes: 4 additions & 106 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,124 +2,22 @@ package cmd

import (
"fmt"
"runtime/debug"

"github.com/RandomCodeSpace/docsiq/internal/buildinfo"
"github.com/spf13/cobra"
)

// Set via -ldflags at build time (see Makefile). These act as overrides.
// When the binary is installed via `go install <module>@<version>`, go install
// cannot pass -ldflags, so these remain at their sentinel defaults and we fall
// back to runtime/debug.ReadBuildInfo() to populate version info from the VCS
// data that `go install` embeds automatically.
var (
Version = "dev"
Commit = "unknown"
Date = "unknown"
)

// VersionInfo holds resolved version metadata for the running binary.
type VersionInfo struct {
Version string
Commit string
Date string
Dirty string // "true", "false", or "unknown"
}

// isSentinel reports whether an ldflags variable is empty or equal to a
// known default placeholder, meaning we should defer to ReadBuildInfo.
func isSentinel(v string) bool {
switch v {
case "", "dev", "unknown":
return true
}
return false
}

// readBuildInfo is a package-level indirection so tests can substitute a
// stub when needed. It mirrors the signature of debug.ReadBuildInfo.
var readBuildInfo = debug.ReadBuildInfo

// versionInfo resolves the current version metadata using the following order:
// 1. -ldflags overrides (if non-sentinel)
// 2. runtime/debug.ReadBuildInfo() (module version + VCS settings)
// 3. "unknown" for any remaining field
func versionInfo() VersionInfo {
vi := VersionInfo{
Version: Version,
Commit: Commit,
Date: Date,
Dirty: "unknown",
}

info, ok := readBuildInfo()
if !ok {
if isSentinel(vi.Version) {
vi.Version = "unknown"
}
if isSentinel(vi.Commit) {
vi.Commit = "unknown"
}
if isSentinel(vi.Date) {
vi.Date = "unknown"
}
return vi
}

// Version: fall back to module version (e.g. "v0.5.0" or "(devel)").
if isSentinel(vi.Version) {
if info.Main.Version != "" {
vi.Version = info.Main.Version
} else {
vi.Version = "unknown"
}
}

// Walk VCS settings for commit/time/modified.
var vcsRev, vcsTime, vcsMod string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
vcsRev = s.Value
case "vcs.time":
vcsTime = s.Value
case "vcs.modified":
vcsMod = s.Value
}
}

if isSentinel(vi.Commit) {
if vcsRev != "" {
vi.Commit = vcsRev
} else {
vi.Commit = "unknown"
}
}
if isSentinel(vi.Date) {
if vcsTime != "" {
vi.Date = vcsTime
} else {
vi.Date = "unknown"
}
}
if vcsMod != "" {
vi.Dirty = vcsMod
}

return vi
}

var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version of docsiq",
Run: func(cmd *cobra.Command, args []string) {
vi := versionInfo()
info := buildinfo.Resolve(false)
dirtySuffix := ""
if vi.Dirty == "true" {
if info.Dirty == "true" {
dirtySuffix = " (dirty)"
}
fmt.Printf("docsiq %s (commit: %s, built: %s)%s\n",
vi.Version, vi.Commit, vi.Date, dirtySuffix)
info.Version, info.Commit, info.BuildDate, dirtySuffix)
},
}

Expand Down
Loading
Loading