From 2b2f32b1b302689c07262c37100e6ee3361db282 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 4 Aug 2026 21:15:02 +0300 Subject: [PATCH 1/2] feat(har): add property-driven HAR capture levels and sensitive mode Centralize HAR capture configuration in the shared har package so HTTP clients can use consistent property-driven behavior. Support metadata-only and full capture levels, optional replayable credentials, and safer permissions for sensitive archives. Reuse the shared middleware and writer across property-based and client-managed capture. --- har/har.go | 13 ++ har/level.go | 49 ++++++ har/metadata.go | 63 +++++++ har/middleware.go | 68 +++++--- har/middleware_test.go | 49 ++++++ har/registry.go | 139 ++++++++++++++++ har/registry_test.go | 363 +++++++++++++++++++++++++++++++++++++++++ har/write.go | 37 +++++ http/client.go | 113 ++----------- 9 files changed, 771 insertions(+), 123 deletions(-) create mode 100644 har/level.go create mode 100644 har/metadata.go create mode 100644 har/registry.go create mode 100644 har/registry_test.go create mode 100644 har/write.go diff --git a/har/har.go b/har/har.go index b0385b3..e122b63 100644 --- a/har/har.go +++ b/har/har.go @@ -12,6 +12,13 @@ const defaultMaxBodySize = 64 * 1024 // 64 KB // -P http.har.maxBodySize=0 to capture full bodies with no cap. const MaxBodySizeProperty = "http.har.maxBodySize" +// SensitiveProperty is the -P/properties key that disables redaction. By +// default credentials in headers, bodies and query strings are masked, so a +// HAR file is safe to share but cannot be replayed. Set -P http.har.sensitive=true +// to capture them verbatim — the resulting file holds live secrets and is +// written with 0600. +const SensitiveProperty = "http.har.sensitive" + // HARConfig controls what the HAR middleware captures and how it redacts. type HARConfig struct { // MaxBodySize is the maximum number of bytes captured per body. @@ -33,6 +40,11 @@ type HARConfig struct { // identifiers (e.g. session ids, national-id fields) that the default // heuristics don't recognise. RedactedBodyKeys []string + + // CaptureSensitive records credentials verbatim instead of masking them, + // so the archive can be replayed against the live API. Honours + // SensitiveProperty; off by default. + CaptureSensitive bool } // DefaultConfig returns a HARConfig with sensible defaults. The per-body @@ -43,6 +55,7 @@ func DefaultConfig() HARConfig { return HARConfig{ MaxBodySize: int64(properties.Int(defaultMaxBodySize, MaxBodySizeProperty)), CaptureContentTypes: []string{"application/json", "application/x-www-form-urlencoded"}, + CaptureSensitive: properties.On(false, SensitiveProperty), } } diff --git a/har/level.go b/har/level.go new file mode 100644 index 0000000..8932d4e --- /dev/null +++ b/har/level.go @@ -0,0 +1,49 @@ +package har + +import ( + "fmt" + "strings" +) + +// Level selects what a HAR collector captures. Borrowed from +// duty/connection/common.go's Debug/Trace split: at Metadata only headers, +// query strings and timings are recorded (no bodies, so no body re-read cost); +// at Full the standard collector middleware captures bodies too. +type Level int + +const ( + Disabled Level = iota + Metadata + Full +) + +func (l Level) String() string { + switch l { + case Metadata: + return "metadata" + case Full: + return "full" + default: + return "disabled" + } +} + +// ParseLevel maps a property value onto a Level. "debug"/"trace" are accepted +// as synonyms for metadata/full, matching the log.level.*.har vocabulary duty +// and commons-db use. An empty string yields def; anything unrecognised is an +// error, so a typo turns into a startup failure rather than silently capturing +// the wrong thing. +func ParseLevel(value string, def Level) (Level, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return def, nil + case "disabled", "off", "none": + return Disabled, nil + case "metadata", "debug": + return Metadata, nil + case "full", "trace", "bodies": + return Full, nil + default: + return def, fmt.Errorf("invalid HAR level %q: expected metadata, full or disabled", value) + } +} diff --git a/har/metadata.go b/har/metadata.go new file mode 100644 index 0000000..f7e2e1c --- /dev/null +++ b/har/metadata.go @@ -0,0 +1,63 @@ +package har + +import ( + "net/http" + "time" + + "github.com/flanksource/commons/http/middlewares" +) + +// NewMetadataMiddleware captures method, URL, sanitized headers, query string, +// status and timings — no request or response bodies. Body sizes use -1 per the +// HAR spec ("size unknown"). Use it when you want a HAR file for traffic +// analysis without paying the body-buffering cost. +// +// Ported from duty/connection/common.go's metadataHARMiddleware, which +// commons/http and commons-db each carried their own copy of. +func NewMetadataMiddleware(cfg HARConfig, handler func(*Entry)) middlewares.Middleware { + if handler == nil { + return func(next http.RoundTripper) http.RoundTripper { + return next + } + } + return func(next http.RoundTripper) http.RoundTripper { + return middlewares.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { + entry := &Entry{ + StartedDateTime: time.Now().UTC().Format(time.RFC3339), + Request: Request{ + Method: req.Method, + URL: harURL(req.URL, cfg), + HTTPVersion: httpVersion(req.Proto), + Cookies: []Cookie{}, + Headers: harHeaders(req.Header, cfg), + QueryString: harQueryString(req.URL.Query(), cfg), + HeadersSize: -1, + BodySize: -1, + }, + } + + waitStart := time.Now() + resp, err := next.RoundTrip(req) + waitMs := float64(time.Since(waitStart).Microseconds()) / 1000.0 + + entry.Timings = Timings{Wait: waitMs} + entry.Time = waitMs + entry.Response = Response{ + Cookies: []Cookie{}, + Headers: []Header{}, + Content: Content{Size: -1}, + HeadersSize: -1, + BodySize: -1, + } + if resp != nil { + entry.Response.Status = resp.StatusCode + entry.Response.StatusText = resp.Status + entry.Response.HTTPVersion = httpVersion(resp.Proto) + entry.Response.Headers = harHeaders(resp.Header, cfg) + } + + handler(entry) + return resp, err + }) + } +} diff --git a/har/middleware.go b/har/middleware.go index b558301..3e38422 100644 --- a/har/middleware.go +++ b/har/middleware.go @@ -64,11 +64,11 @@ func CaptureRedirect(req *http.Request, resp *http.Response, cfg HARConfig) *Ent func buildRequest(req *http.Request, cfg HARConfig) Request { har := Request{ Method: req.Method, - URL: redactURL(req.URL, cfg.RedactedBodyKeys), + URL: harURL(req.URL, cfg), HTTPVersion: httpVersion(req.Proto), Cookies: []Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(req.Header, cfg.RedactedHeaders...)), - QueryString: toQueryString(req.URL.Query(), cfg.RedactedBodyKeys), + Headers: harHeaders(req.Header, cfg), + QueryString: harQueryString(req.URL.Query(), cfg), HeadersSize: -1, BodySize: -1, } @@ -80,7 +80,7 @@ func buildRequest(req *http.Request, cfg HARConfig) Request { har.BodySize = int64(len(body.raw)) har.PostData = &PostData{ MimeType: ct, - Text: redactBody(body.text, ct, cfg.RedactedBodyKeys), + Text: harBody(body.text, ct, cfg), } } @@ -93,7 +93,7 @@ func buildResponse(resp *http.Response, cfg HARConfig) Response { StatusText: resp.Status, HTTPVersion: httpVersion(resp.Proto), Cookies: []Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(resp.Header, cfg.RedactedHeaders...)), + Headers: harHeaders(resp.Header, cfg), RedirectURL: "", HeadersSize: -1, BodySize: -1, @@ -107,7 +107,7 @@ func buildResponse(resp *http.Response, cfg HARConfig) Response { har.Content = Content{ Size: body.totalSize, MimeType: ct, - Text: redactBody(body.text, ct, cfg.RedactedBodyKeys), + Text: harBody(body.text, ct, cfg), Truncated: body.truncated, } } @@ -154,6 +154,48 @@ func shouldCapture(contentType string, allowed []string) bool { return false } +// harHeaders, harURL, harQueryString and harBody are the single gate through +// which every captured value passes. cfg.CaptureSensitive (-Phttp.har.sensitive) +// bypasses redaction so the archive can be replayed against the live API; by +// default credentials are masked with logger.PrintableSecret. +func harHeaders(headers http.Header, cfg HARConfig) []Header { + if cfg.CaptureSensitive { + return toHARHeaders(headers) + } + return toHARHeaders(logger.SanitizeHeaders(headers, cfg.RedactedHeaders...)) +} + +func harURL(u *url.URL, cfg HARConfig) string { + if u == nil { + return "" + } + if cfg.CaptureSensitive { + return u.String() + } + return redactURL(u, cfg.RedactedBodyKeys) +} + +func harQueryString(query url.Values, cfg HARConfig) []QueryString { + qs := make([]QueryString, 0, len(query)) + for k, vs := range query { + redact := !cfg.CaptureSensitive && isRedactedKey(k, cfg.RedactedBodyKeys) + for _, v := range vs { + if redact { + v = logger.PrintableSecret(v) + } + qs = append(qs, QueryString{Name: k, Value: v}) + } + } + return qs +} + +func harBody(text, contentType string, cfg HARConfig) string { + if cfg.CaptureSensitive { + return text + } + return redactBody(text, contentType, cfg.RedactedBodyKeys) +} + func redactBody(text, contentType string, extraKeys []string) string { ct := strings.ToLower(strings.Split(contentType, ";")[0]) ct = strings.TrimSpace(ct) @@ -282,20 +324,6 @@ func toHARHeaders(h http.Header) []Header { return headers } -func toQueryString(q url.Values, extraKeys []string) []QueryString { - qs := make([]QueryString, 0, len(q)) - for k, vs := range q { - redact := isRedactedKey(k, extraKeys) - for _, v := range vs { - if redact { - v = logger.PrintableSecret(v) - } - qs = append(qs, QueryString{Name: k, Value: v}) - } - } - return qs -} - func httpVersion(proto string) string { if proto == "" { return "HTTP/1.1" diff --git a/har/middleware_test.go b/har/middleware_test.go index d65201f..ecd7e9c 100644 --- a/har/middleware_test.go +++ b/har/middleware_test.go @@ -88,6 +88,55 @@ func TestHAR_AuthorizationHeaderRedacted(t *testing.T) { } } +// TestHAR_CaptureSensitiveKeepsCredentials pins the -Phttp.har.sensitive escape +// hatch at the config layer: with it set, the archive is replayable because +// every value is verbatim, including a query parameter the default heuristics +// would otherwise mask. +func TestHAR_CaptureSensitiveKeepsCredentials(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(204) + })) + defer srv.Close() + + const ( + secret = "Bearer supersecret" + apiKey = "sk-live-1234567890" + jsonBody = `{"api_key":"sk-live-1234567890"}` + ) + cfg := har.DefaultConfig() + cfg.CaptureSensitive = true + + entry := captureOne(t, cfg, srv, http.MethodPost, "/?token="+apiKey, + strings.NewReader(jsonBody), map[string]string{ + "Authorization": secret, + "Content-Type": "application/json", + }) + + if got := headerValue(entry.Request.Headers, "Authorization"); got != secret { + t.Errorf("Authorization = %q, want the verbatim value %q", got, secret) + } + if !strings.Contains(entry.Request.URL, apiKey) { + t.Errorf("URL %q dropped the query credential", entry.Request.URL) + } + if entry.Request.PostData == nil || entry.Request.PostData.Text != jsonBody { + t.Errorf("request body was redacted: %+v", entry.Request.PostData) + } + for _, q := range entry.Request.QueryString { + if q.Name == "token" && q.Value != apiKey { + t.Errorf("query string token = %q, want %q", q.Value, apiKey) + } + } +} + +func headerValue(headers []har.Header, name string) string { + for _, h := range headers { + if strings.EqualFold(h.Name, name) { + return h.Value + } + } + return "" +} + func TestHAR_CookieHeaderRedacted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Set-Cookie", "session=abc123; Path=/") diff --git a/har/registry.go b/har/registry.go new file mode 100644 index 0000000..d2dea45 --- /dev/null +++ b/har/registry.go @@ -0,0 +1,139 @@ +package har + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "sync" + + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" +) + +// PropertyPrefix is the namespace the registry resolves its properties under. +const PropertyPrefix = "http." + +// Registry turns -P properties into HAR capture: it resolves the output path +// and level per feature, owns one collector per output file, and writes them +// all on Flush. +// +// Properties are looked up per-feature first, then globally: +// +// http..har / http.har output path; unset disables capture +// http..har.level / http.har.level "full" (default) or "metadata" +// http.har.sensitive capture credentials verbatim +// http.har.maxBodySize per-body capture cap +// +// Collectors are deduplicated by absolute path, so several features writing to +// the same file share one archive. +type Registry struct { + prefix string + log logger.Logger + collectors sync.Map // absolute path -> *Collector +} + +// NewRegistry returns a registry that announces capture as it is enabled and +// reports flush results on log. Pass nil for the shared "har" logger, whose +// level can be raised on its own with -Plog.level.har=debug. +func NewRegistry(log logger.Logger) *Registry { + if log == nil { + log = logger.GetLogger("har") + } + return &Registry{prefix: PropertyPrefix, log: log} +} + +// For reports the collector, absolute output path and level configured for +// feature. A nil collector means capture is off. The shape matches +// http.CommonsHTTPContext's HARFor apart from the error, which reports an +// unusable http.har.level rather than silently capturing the wrong thing. +func (r *Registry) For(feature string) (*Collector, string, Level, error) { + path := r.lookup(feature, "har") + if path == "" { + return nil, "", Disabled, nil + } + + level, err := ParseLevel(r.lookup(feature, "har.level"), Full) + if err != nil { + return nil, "", Disabled, fmt.Errorf("%s%s: %w", r.prefix, "har.level", err) + } + if level == Disabled { + return nil, "", Disabled, nil + } + + abs, err := filepath.Abs(path) + if err != nil { + return nil, "", Disabled, fmt.Errorf("resolve HAR path %q: %w", path, err) + } + collector, created := r.collectorFor(abs) + if created { + r.log.Infof("capturing HAR to %s (%s)", abs, describeMode(collector.Config, level)) + } + return collector, abs, level, nil +} + +// describeMode renders the capture mode for the announcement, e.g. +// "level=full" or "level=full, sensitive". +func describeMode(cfg HARConfig, level Level) string { + mode := "level=" + level.String() + if cfg.CaptureSensitive { + mode += ", sensitive" + } + return mode +} + +// Transport wraps base with the capture middleware configured for feature, or +// returns base unchanged when capture is off. +func (r *Registry) Transport(feature string, base http.RoundTripper) (http.RoundTripper, error) { + collector, _, level, err := r.For(feature) + if err != nil || collector == nil { + return base, err + } + if base == nil { + base = http.DefaultTransport + } + + if level == Metadata { + return NewMetadataMiddleware(collector.Config, collector.Add)(base), nil + } + return collector.Middleware()(base), nil +} + +// Flush writes every collector to its file. Collectors are kept afterwards, so +// a second call rewrites the same files rather than losing entries. +func (r *Registry) Flush() error { + var errs []error + r.collectors.Range(func(key, value any) bool { + path, collector := key.(string), value.(*Collector) + if err := WriteFile(collector, path); err != nil { + errs = append(errs, fmt.Errorf("write HAR %s: %w", path, err)) + return true + } + r.log.Infof("wrote HAR %s (%d entries)", path, len(collector.Entries())) + if collector.Config.CaptureSensitive { + r.log.Warnf("%s contains unredacted credentials (%s=true)", path, SensitiveProperty) + } + return true + }) + return errors.Join(errs...) +} + +// collectorFor returns the collector for absPath, reporting whether this call +// created it. Only the creating call announces, so one archive logs one line no +// matter how many features resolve to it. +func (r *Registry) collectorFor(absPath string) (*Collector, bool) { + if existing, ok := r.collectors.Load(absPath); ok { + return existing.(*Collector), false + } + actual, loaded := r.collectors.LoadOrStore(absPath, NewCollector(DefaultConfig())) + return actual.(*Collector), !loaded +} + +func (r *Registry) lookup(feature, suffix string) string { + if feature != "" { + if v := properties.String("", r.prefix+feature+"."+suffix); v != "" { + return v + } + } + return properties.String("", r.prefix+suffix) +} diff --git a/har/registry_test.go b/har/registry_test.go new file mode 100644 index 0000000..2941707 --- /dev/null +++ b/har/registry_test.go @@ -0,0 +1,363 @@ +package har_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/commons/har" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" +) + +// newRegistry returns a registry whose log output is captured, so tests can +// assert on the capture announcement and none of them print to the test log. +func newRegistry(t *testing.T) (*har.Registry, *bytes.Buffer) { + t.Helper() + var out bytes.Buffer + return har.NewRegistry(logger.NewWithWriter(&out)), &out +} + +// setProperty sets a -P property for the duration of the test. Properties are +// process-global, so every test that touches one must restore it. +func setProperty(t *testing.T, key, value string) { + t.Helper() + properties.Set(key, value) + t.Cleanup(func() { properties.Set(key, "") }) +} + +// jsonServer answers every request with a fixed JSON body and echoes nothing, +// so assertions are about what the registry captured, not about the server. +func jsonServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + t.Cleanup(srv.Close) + return srv +} + +// get issues one request through transport and drains the response. +func get(t *testing.T, transport http.RoundTripper, url string, headers map[string]string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func readHAR(t *testing.T, path string) har.File { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var file har.File + if err := json.Unmarshal(data, &file); err != nil { + t.Fatalf("HAR at %s is not valid JSON: %v", path, err) + } + if file.Log.Version != "1.2" { + t.Errorf("expected HAR version 1.2, got %q", file.Log.Version) + } + return file +} + +func TestRegistry_DisabledWithoutProperty(t *testing.T) { + registry, _ := newRegistry(t) + + collector, path, level, err := registry.For("http") + if err != nil { + t.Fatal(err) + } + if collector != nil || path != "" || level != har.Disabled { + t.Fatalf("expected no capture, got collector=%v path=%q level=%v", collector != nil, path, level) + } + + base := http.DefaultTransport + wrapped, err := registry.Transport("http", base) + if err != nil { + t.Fatal(err) + } + if wrapped != base { + t.Error("transport must be returned unchanged when http.har is unset") + } +} + +func TestRegistry_FeaturePropertyOverridesGlobal(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "global.har")) + setProperty(t, "http.chat.har", filepath.Join(dir, "chat.har")) + registry, _ := newRegistry(t) + + for _, tc := range []struct{ feature, want string }{ + {feature: "chat", want: "chat.har"}, + {feature: "models", want: "global.har"}, + {feature: "", want: "global.har"}, + } { + _, path, _, err := registry.For(tc.feature) + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(dir, tc.want) { + t.Errorf("feature %q resolved to %s, want %s", tc.feature, path, tc.want) + } + } +} + +func TestRegistry_DedupesCollectorsByAbsolutePath(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "shared.har")) + registry, _ := newRegistry(t) + + first, _, _, err := registry.For("chat") + if err != nil { + t.Fatal(err) + } + second, _, _, err := registry.For("models") + if err != nil { + t.Fatal(err) + } + if first != second { + t.Error("features writing to the same file must share one collector") + } +} + +func TestRegistry_Level(t *testing.T) { + dir := t.TempDir() + setProperty(t, "http.har", filepath.Join(dir, "trace.har")) + + for _, tc := range []struct { + name string + value string + want har.Level + wantErr bool + }{ + {name: "defaults to full", value: "", want: har.Full}, + {name: "metadata downgrades", value: "metadata", want: har.Metadata}, + {name: "debug is a metadata synonym", value: "debug", want: har.Metadata}, + {name: "trace is a full synonym", value: "trace", want: har.Full}, + {name: "off disables capture", value: "off", want: har.Disabled}, + {name: "a typo is an error", value: "verbose", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + setProperty(t, "http.har.level", tc.value) + + registry, _ := newRegistry(t) + _, _, level, err := registry.For("http") + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error for level %q", tc.value) + } + return + } + if err != nil { + t.Fatal(err) + } + if level != tc.want { + t.Errorf("level = %v, want %v", level, tc.want) + } + }) + } +} + +// TestRegistry_AnnouncesCaptureOnce pins the signal that capture is on: without +// it, a run gives no indication until the archive is flushed at exit. +func TestRegistry_AnnouncesCaptureOnce(t *testing.T) { + const announcement = "capturing HAR to " + dir := t.TempDir() + + t.Run("silent when capture is off", func(t *testing.T) { + registry, out := newRegistry(t) + if _, _, _, err := registry.For("http"); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), announcement) { + t.Errorf("nothing should be announced when http.har is unset, got:\n%s", out) + } + }) + + for _, tc := range []struct { + name string + level string + sensitive string + wantMode string + }{ + {name: "full is the default mode", wantMode: "level=full"}, + {name: "metadata is named", level: "metadata", wantMode: "level=metadata"}, + {name: "sensitive is called out", sensitive: "true", wantMode: "level=full, sensitive"}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.level", tc.level) + setProperty(t, "http.har.sensitive", tc.sensitive) + registry, out := newRegistry(t) + + // Two resolutions of the same archive, through both entry points. + if _, _, _, err := registry.For("chat"); err != nil { + t.Fatal(err) + } + if _, err := registry.Transport("models", http.DefaultTransport); err != nil { + t.Fatal(err) + } + + if got := strings.Count(out.String(), announcement); got != 1 { + t.Fatalf("announced %d times, want exactly 1:\n%s", got, out) + } + want := fmt.Sprintf("%s%s (%s)", announcement, path, tc.wantMode) + if !strings.Contains(out.String(), want) { + t.Errorf("expected %q, got:\n%s", want, out) + } + }) + } + + t.Run("one line per archive", func(t *testing.T) { + setProperty(t, "http.har", filepath.Join(dir, "global.har")) + setProperty(t, "http.chat.har", filepath.Join(dir, "chat.har")) + registry, out := newRegistry(t) + + for _, feature := range []string{"chat", "models"} { + if _, _, _, err := registry.For(feature); err != nil { + t.Fatal(err) + } + } + if got := strings.Count(out.String(), announcement); got != 2 { + t.Errorf("two distinct archives must announce twice, got %d:\n%s", got, out) + } + }) +} + +func TestRegistry_CapturesFullBodiesAndFlushes(t *testing.T) { + const body = `{"status":"ok"}` + path := filepath.Join(t.TempDir(), "trace.har") + setProperty(t, "http.har", path) + srv := jsonServer(t, body) + registry, _ := newRegistry(t) + + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", nil) + get(t, transport, srv.URL+"/pong", nil) + + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + entries := readHAR(t, path).Log.Entries + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if !strings.HasSuffix(entries[0].Request.URL, "/ping") { + t.Errorf("unexpected first URL: %s", entries[0].Request.URL) + } + if entries[0].Response.Content.Text != body { + t.Errorf("expected the response body to be captured, got %q", entries[0].Response.Content.Text) + } + + if mode := fileMode(t, path); mode != 0o644 { + t.Errorf("expected mode 0644 for a redacted HAR, got %04o", mode) + } +} + +func TestRegistry_MetadataLevelOmitsBodies(t *testing.T) { + path := filepath.Join(t.TempDir(), "meta.har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.level", "metadata") + srv := jsonServer(t, `{"status":"ok"}`) + registry, _ := newRegistry(t) + + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", nil) + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + entries := readHAR(t, path).Log.Entries + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Response.Content.Text != "" { + t.Errorf("metadata level must not capture bodies, got %q", entries[0].Response.Content.Text) + } + if entries[0].Response.BodySize != -1 || entries[0].Request.BodySize != -1 { + t.Errorf("metadata level must report unknown body sizes, got req=%d resp=%d", + entries[0].Request.BodySize, entries[0].Response.BodySize) + } + if entries[0].Response.Status != http.StatusOK { + t.Errorf("metadata level must still record the status, got %d", entries[0].Response.Status) + } +} + +func TestRegistry_SensitiveCapturesCredentialsAndTightensMode(t *testing.T) { + const token = "Bearer sk-live-abcdefghijklmnop" + dir := t.TempDir() + srv := jsonServer(t, `{"status":"ok"}`) + + for _, tc := range []struct { + name string + sensitive string + wantToken bool + wantMode os.FileMode + }{ + {name: "redacted by default", sensitive: "", wantMode: 0o644}, + {name: "verbatim when enabled", sensitive: "true", wantToken: true, wantMode: 0o600}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, tc.name+".har") + setProperty(t, "http.har", path) + setProperty(t, "http.har.sensitive", tc.sensitive) + + registry, _ := newRegistry(t) + transport, err := registry.Transport("http", http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + get(t, transport, srv.URL+"/ping", map[string]string{"Authorization": token}) + if err := registry.Flush(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Contains(string(raw), token); got != tc.wantToken { + t.Errorf("token present = %v, want %v", got, tc.wantToken) + } + if mode := fileMode(t, path); mode != tc.wantMode { + t.Errorf("mode = %04o, want %04o", mode, tc.wantMode) + } + }) + } +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode().Perm() +} diff --git a/har/write.go b/har/write.go new file mode 100644 index 0000000..e70d716 --- /dev/null +++ b/har/write.go @@ -0,0 +1,37 @@ +package har + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" +) + +// CreatorName identifies commons as the producer in the HAR envelope. +const CreatorName = "flanksource-commons" + +// WriteFile serializes collector.Entries() into a HAR 1.2 file at path. A +// collector configured with CaptureSensitive holds unmasked credentials, so its +// file is written 0600 rather than 0644. +func WriteFile(collector *Collector, path string) error { + file := File{ + Log: Log{ + Version: "1.2", + Creator: Creator{Name: CreatorName, Version: "0"}, + Pages: []Page{}, + Entries: collector.Entries(), + }, + } + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshal HAR: %w", err) + } + return os.WriteFile(path, append(data, '\n'), fileMode(collector)) +} + +func fileMode(collector *Collector) fs.FileMode { + if collector != nil && collector.Config.CaptureSensitive { + return 0o600 + } + return 0o644 +} diff --git a/http/client.go b/http/client.go index 4727a19..ab802eb 100644 --- a/http/client.go +++ b/http/client.go @@ -43,7 +43,6 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/json" "fmt" "net/http" "net/url" @@ -783,12 +782,14 @@ func (c *Client) getLogger() logger.Logger { // split — at Metadata, only request/response headers + timing are // captured (no bodies, no body re-read cost). At Full, the standard // collector middleware captures bodies too. -type HARLevel int +// The levels live in commons/har so the middleware, the property registry and +// this client all speak one enum; these names are kept for callers. +type HARLevel = har.Level const ( - HARDisabled HARLevel = iota - HARMetadata - HARFull + HARDisabled = har.Disabled + HARMetadata = har.Metadata + HARFull = har.Full ) // CommonsHTTPContext is the narrow interface a context object implements @@ -834,112 +835,18 @@ func (c *Client) WithContext(ctx CommonsHTTPContext, feature string) *Client { case HARFull: c = c.HARCollector(collector) case HARMetadata: - c.Use(metadataHARMiddleware(collector)) + c.Use(har.NewMetadataMiddleware(collector.Config, collector.Add)) } } return c } -// metadataHARMiddleware captures method, URL, sanitized headers, query -// string, status, and timings — no request or response bodies. Ported -// from duty/connection/common.go's metadataHARMiddleware. Body sizes -// use -1 per HAR spec ("size unknown"). Useful when the caller wants a -// HAR file for traffic analysis without paying the body-buffering cost. -func metadataHARMiddleware(collector *har.Collector) middlewares.Middleware { - return func(next http.RoundTripper) http.RoundTripper { - return middlewares.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { - started := time.Now() - entry := &har.Entry{ - StartedDateTime: started.UTC().Format(time.RFC3339), - Request: har.Request{ - Method: req.Method, - URL: req.URL.String(), - HTTPVersion: harHTTPVersion(req.Proto), - Cookies: []har.Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(req.Header)), - QueryString: toHARQueryString(req.URL.Query()), - HeadersSize: -1, - BodySize: -1, - }, - } - - waitStart := time.Now() - resp, err := next.RoundTrip(req) - waitMs := float64(time.Since(waitStart).Microseconds()) / 1000.0 - - entry.Timings = har.Timings{Wait: waitMs} - entry.Time = waitMs - if resp != nil { - entry.Response = har.Response{ - Status: resp.StatusCode, - StatusText: resp.Status, - HTTPVersion: harHTTPVersion(resp.Proto), - Cookies: []har.Cookie{}, - Headers: toHARHeaders(logger.SanitizeHeaders(resp.Header)), - Content: har.Content{Size: -1}, - HeadersSize: -1, - BodySize: -1, - } - } else { - entry.Response = har.Response{ - Cookies: []har.Cookie{}, - Headers: []har.Header{}, - Content: har.Content{Size: -1}, - HeadersSize: -1, - BodySize: -1, - } - } - - collector.Add(entry) - return resp, err - }) - } -} - -func toHARHeaders(h http.Header) []har.Header { - headers := make([]har.Header, 0, len(h)) - for name, vals := range h { - for _, v := range vals { - headers = append(headers, har.Header{Name: name, Value: v}) - } - } - return headers -} - -func toHARQueryString(q url.Values) []har.QueryString { - qs := make([]har.QueryString, 0, len(q)) - for k, vs := range q { - for _, v := range vs { - qs = append(qs, har.QueryString{Name: k, Value: v}) - } - } - return qs -} - -func harHTTPVersion(proto string) string { - if strings.TrimSpace(proto) == "" { - return "HTTP/1.1" - } - return proto -} - // WriteHARFile serializes collector.Entries() into a HAR 1.2 file at // path. Designed for use from a context.AfterFunc hook owned by the -// caller — commons/http does not register any lifecycle itself. +// caller — commons/http does not register any lifecycle itself, though +// har.Registry does it for property-driven capture. func WriteHARFile(collector *har.Collector, path string) error { - file := har.File{ - Log: har.Log{ - Version: "1.2", - Creator: har.Creator{Name: "flanksource-commons", Version: "0"}, - Pages: []har.Page{}, - Entries: collector.Entries(), - }, - } - data, err := json.MarshalIndent(file, "", " ") - if err != nil { - return fmt.Errorf("marshal HAR: %w", err) - } - return os.WriteFile(path, append(data, '\n'), 0o644) + return har.WriteFile(collector, path) } // HAR enables HAR capture with default config. From 471d66cb9e2337d5f6b50a1a43dff7b4c12af126 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 4 Aug 2026 21:15:13 +0300 Subject: [PATCH 2/2] feat(help): add shared runtime configuration help Add reusable operator help for logging, HTTP tracing, HAR capture, properties, and output formats. Derive the HTTP trace ladder from runtime configuration and cover topic selection and rendering with tests. --- help/format.go | 18 +++++ help/har.go | 20 +++++ help/help.go | 103 ++++++++++++++++++++++++ help/help_suite_test.go | 13 +++ help/help_test.go | 171 ++++++++++++++++++++++++++++++++++++++++ help/http.go | 129 ++++++++++++++++++++++++++++++ help/logging.go | 33 ++++++++ 7 files changed, 487 insertions(+) create mode 100644 help/format.go create mode 100644 help/har.go create mode 100644 help/help.go create mode 100644 help/help_suite_test.go create mode 100644 help/help_test.go create mode 100644 help/http.go create mode 100644 help/logging.go diff --git a/help/format.go b/help/format.go new file mode 100644 index 0000000..9d76231 --- /dev/null +++ b/help/format.go @@ -0,0 +1,18 @@ +package help + +import "github.com/flanksource/clicky/api" + +func formatTopic() api.Text { + return lines( + knob("--format=", "pretty (default), json, yaml, csv, html, markdown, pdf, slack"), + knob("--json, --yaml, --csv, --pdf", "shorthands for the matching --format value"), + knob("--markdown, --html, --pretty", "the remaining --format shorthands"), + knob("--tree, --table", "display structure, additive with the chosen format"), + knob("--filter=", "CEL expression filtering the data before rendering"), + knob("--no-color", "disable ANSI colour in rendered output"), + note("--format also takes a comma separated list of format=file sinks, which writes"), + note("each rendering to its own file instead of stdout:"), + example("--format=json=report.json,markdown=summary.md"), + note("These flags come from clicky; a CLI accepts them only if it binds that group."), + ) +} diff --git a/help/har.go b/help/har.go new file mode 100644 index 0000000..7e98d2e --- /dev/null +++ b/help/har.go @@ -0,0 +1,20 @@ +package help + +import "github.com/flanksource/clicky/api" + +func harTopic() api.Text { + return lines( + note("A HAR file records whole request/response pairs — including OAuth token fetches,"), + note("redirect hops and retries — for import into browser DevTools or any HAR viewer."), + note("Naming an output path is what turns capture on:"), + example("-Phttp.har=trace.har"), + knob("-Phttp.har=", "capture to ; unset means no capture"), + knob("-Phttp..har=", "capture one subsystem to its own file"), + knob("-Phttp.har.level=metadata", "headers, query and timings only; default is full bodies"), + knob("-Phttp.har.sensitive=true", "keep credentials verbatim so the archive replays"), + knob("-Phttp.har.maxBodySize", "bytes captured per body (default 65536, 0 = uncapped)"), + note("Bodies are captured for application/json and application/x-www-form-urlencoded"), + note("only. By default headers, body keys and query strings are redacted with the same"), + note("rules as the wire log; http.har.sensitive turns that off and writes the file 0600."), + ) +} diff --git a/help/help.go b/help/help.go new file mode 100644 index 0000000..03c880b --- /dev/null +++ b/help/help.go @@ -0,0 +1,103 @@ +// Package help renders operator-facing documentation for the runtime knobs +// commons owns — log verbosity, log formatting, HTTP wire tracing, HAR capture +// and output formatting — as a clicky Textable, so every CLI built on commons +// can splice the same block into its --help instead of re-documenting the same +// properties (or, more often, not documenting them at all). +// +// The package deliberately sits outside logger/ and http/: clicky imports +// commons/{logger,text,collections,context}, so rendering help from any of +// those packages would create an import cycle. +package help + +import ( + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/collections" +) + +// Topic is one named section of the help document. +type Topic struct { + // Name is the stable selector used to filter topics, e.g. "http". + Name string + + // Title is the section heading. + Title string + + // Body is the rendered section, excluding its heading. + Body api.Text +} + +// Topics returns every section in display order. +func Topics() []Topic { + return []Topic{ + {Name: "properties", Title: "Runtime properties", Body: propertiesTopic()}, + {Name: "logging", Title: "Logging verbosity", Body: loggingTopic()}, + {Name: "log-format", Title: "Log formatting", Body: logFormatTopic()}, + {Name: "http", Title: "HTTP wire logging", Body: httpTopic()}, + {Name: "har", Title: "HAR capture", Body: harTopic()}, + {Name: "format", Title: "Output formatting", Body: formatTopic()}, + } +} + +// Help composes the selected topics into a single document. With no names every +// topic is included; names are matched with collections.MatchItems, so "http", +// "!har" and "log*" all select as expected. The result is an api.Text, which +// satisfies api.Textable — callers pick String(), ANSI(), Markdown() or HTML(). +func Help(names ...string) api.Text { + doc := api.Text{} + for _, topic := range Topics() { + if !collections.MatchItems(topic.Name, names...) { + continue + } + if !doc.IsEmpty() { + doc = doc.NewLine() + } + doc = doc.AddText(topic.Title, styleTitle).NewLine().Add(topic.Body) + } + return doc +} + +const ( + styleTitle = "font-bold text-blue-400" + styleCode = "font-mono text-yellow-600" + styleMuted = "text-gray-500" + + // knobWidth is the width of the flag/property column, chosen so the widest + // documented knob still leaves room for its description on an 80 column + // terminal. + knobWidth = 32 +) + +// knob renders an aligned " " line. +func knob(name, description string) api.Text { + return api.Text{}. + AddText(" "+rpad(name, knobWidth), styleCode). + AddText(description, styleMuted) +} + +// note renders an indented prose line. +func note(text string) api.Text { + return api.Text{}.AddText(" " + text) +} + +// example renders an indented copy-pasteable command fragment. +func example(text string) api.Text { + return api.Text{}.AddText(" "+text, styleCode) +} + +// lines stacks body lines into a single Text, one per line. +func lines(items ...api.Text) api.Text { + body := api.Text{} + for _, item := range items { + body = body.Add(item).NewLine() + } + return body +} + +func rpad(s string, width int) string { + if len(s) >= width { + return s + " " + } + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/help/help_suite_test.go b/help/help_suite_test.go new file mode 100644 index 0000000..75361c2 --- /dev/null +++ b/help/help_suite_test.go @@ -0,0 +1,13 @@ +package help + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHelp(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Help Suite") +} diff --git a/help/help_test.go b/help/help_test.go new file mode 100644 index 0000000..fd5af9d --- /dev/null +++ b/help/help_test.go @@ -0,0 +1,171 @@ +package help + +import ( + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/http" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// documentedKeys are the knobs the help exists to make discoverable. A section +// that silently stops rendering fails here. +var documentedKeys = []string{ + "-P/--properties", + "--log-level", + "log.level.", + "--json-logs", + "HTTP_LOG_BASE_LEVEL", + "http.log.response.body.length", + "http.har.maxBodySize", + "--format", +} + +// renderings covers every output format api.Textable promises, so a section +// that renders in ANSI but collapses to nothing in Markdown is caught. +func renderings(text api.Text) map[string]string { + return map[string]string{ + "String": text.String(), + "ANSI": text.ANSI(), + "Markdown": text.Markdown(), + "HTML": text.HTML(), + } +} + +var _ = Describe("Topics", func() { + It("returns the documented sections in display order", func() { + var names []string + for _, topic := range Topics() { + names = append(names, topic.Name) + } + Expect(names).To(Equal([]string{"properties", "logging", "log-format", "http", "har", "format"})) + }) + + It("gives every topic a title and a body that renders in every format", func() { + for _, topic := range Topics() { + Expect(topic.Title).ToNot(BeEmpty(), topic.Name) + for format, out := range renderings(topic.Body) { + Expect(strings.TrimSpace(out)).ToNot(BeEmpty(), "%s topic rendered empty as %s", topic.Name, format) + } + } + }) +}) + +var _ = Describe("Help", func() { + It("documents every knob in every rendering", func() { + for format, out := range renderings(Help()) { + for _, key := range documentedKeys { + Expect(out).To(ContainSubstring(key), "%s missing from %s output", key, format) + } + } + }) + + It("includes each topic's title", func() { + out := Help().String() + for _, topic := range Topics() { + Expect(out).To(ContainSubstring(topic.Title)) + } + }) + + It("selects a single topic by name", func() { + out := Help("http").String() + Expect(out).To(ContainSubstring("HTTP_LOG_BASE_LEVEL")) + Expect(out).ToNot(ContainSubstring("http.har.maxBodySize")) + Expect(out).ToNot(ContainSubstring("--json-logs")) + }) + + It("excludes a topic with a negated name", func() { + out := Help("!har").String() + Expect(out).ToNot(ContainSubstring("http.har.maxBodySize")) + Expect(out).To(ContainSubstring("HTTP_LOG_BASE_LEVEL")) + Expect(out).To(ContainSubstring("--json-logs")) + }) +}) + +var _ = Describe("the HTTP trace ladder", func() { + It("describes each level exactly as TraceConfigForLogLevel builds it", func() { + for level := logger.Warn; level <= logger.Trace4; level++ { + config, enabled := http.TraceConfigForLogLevel(level) + captures := strings.Join(traceCaptures(config, enabled), ", ") + + Expect(captures == "").To(Equal(!enabled), "%s claims %q but enabled=%v", level, captures, enabled) + if !enabled { + continue + } + Expect(strings.Contains(captures, "headers")).To(Equal(config.Headers || config.ResponseHeaders), level.String()) + Expect(strings.Contains(captures, "query and form params")).To(Equal(config.QueryParam || config.FormParams), level.String()) + Expect(strings.Contains(captures, "request bodies")).To(Equal(config.Body), level.String()) + Expect(strings.Contains(captures, "response bodies")).To(Equal(config.Response), level.String()) + Expect(strings.Contains(captures, "TLS summary")).To(Equal(config.TLS), level.String()) + } + }) + + It("folds away levels that capture nothing new", func() { + var captures []string + for _, row := range traceLevelRows() { + captures = append(captures, row.Captures) + } + Expect(captures).To(HaveLen(len(dedupeConsecutive(captures))), "consecutive rungs must differ") + }) + + It("renders each extra rung as the delta over the one below it", func() { + rows := traceLevelRows() + deltas := map[logger.LogLevel]string{} + for _, row := range rows { + if strings.HasPrefix(row.Captures, "+ ") { + deltas[row.Level] = strings.TrimPrefix(row.Captures, "+ ") + } + } + Expect(deltas).ToNot(BeEmpty(), "the ladder must have at least one incremental rung") + + for level, delta := range deltas { + below, _ := http.TraceConfigForLogLevel(level - 1) + config, _ := http.TraceConfigForLogLevel(level) + added := traceCaptures(config, true)[len(traceCaptures(below, true)):] + Expect(delta).To(Equal(strings.Join(added, ", ")), level.String()) + } + }) + + It("renders the -v count that reaches each level", func() { + Expect(levelFlag(logger.Info)).To(Equal("--log-level=info")) + Expect(levelFlag(logger.Debug)).To(Equal("-v")) + Expect(levelFlag(logger.Trace)).To(Equal("-vv")) + Expect(levelFlag(logger.Trace2)).To(Equal("-vvvv")) + }) + + It("shifts with the configured base level", func() { + bodiesAt := func() logger.LogLevel { + for _, row := range traceLevelRows() { + if strings.Contains(row.Captures, "request bodies") { + return row.Level + } + } + Fail("no rung captures request bodies") + return logger.Silent + } + + defaultLevel := bodiesAt() + + properties.Set("http.log.base-level", "trace") + DeferCleanup(func() { properties.Set("http.log.base-level", "") }) + + Expect(bodiesAt()).To(Equal(defaultLevel+1), + "raising the base level by one must move the whole ladder up by one") + }) +}) + +// dedupeConsecutive drops runs of equal adjacent entries. +func dedupeConsecutive(values []string) []string { + var out []string + for _, v := range values { + if len(out) > 0 && out[len(out)-1] == v { + continue + } + out = append(out, v) + } + return out +} diff --git a/help/http.go b/help/http.go new file mode 100644 index 0000000..d831f39 --- /dev/null +++ b/help/http.go @@ -0,0 +1,129 @@ +package help + +import ( + "slices" + "strings" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/http" + "github.com/flanksource/commons/logger" +) + +func httpTopic() api.Text { + body := lines( + note("Clients built on commons/http log their traffic on a ladder relative to a base"), + note("level (debug by default). Credentials in headers, bodies and query strings are"), + note("redacted before anything is written."), + ) + body = body.Add(api.NewTableFrom(traceLevelRows())).NewLine() + return body.Add(lines( + knob("-Plog.level.http=", "raise the http logger alone, leaving the rest quiet"), + knob("HTTP_LOG_BASE_LEVEL=", "move the whole ladder; also -Phttp.log.base-level"), + knob("-Phttp.log.response.body.length", "bytes of response body logged (default 4096)"), + knob("-Phttp.request.maxBufferSize", "bytes of a streamed request body buffered for retry"), + knob("-Phttp.body.disabled", "never log request or response bodies"), + knob("-Phttp.headers.disabled", "never log request or response headers"), + note("CLIs that expose -Phttp.log= take a spec instead of a level: access,"), + note("headers, body, request, response, trace or all, plus the additive tokens"), + note("queryParam, formParams, responseHeaders, tls, timing and auth."), + note("To keep the exchanges rather than watch them scroll past, see HAR capture."), + )) +} + +// traceLevelRow is one rung of the HTTP trace ladder. +type traceLevelRow struct { + Level logger.LogLevel + Flag string + Captures string +} + +// Columns implements api.TableProvider. +func (traceLevelRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("level").Label("Level").Style("font-mono").Build(), + api.Column("flag").Label("Flag").Style("font-mono text-yellow-600").Build(), + api.Column("captures").Label("Captures").Build(), + } +} + +// Row implements api.TableProvider. +func (r traceLevelRow) Row() map[string]any { + return map[string]any{ + "level": r.Level.String(), + "flag": r.Flag, + "captures": r.Captures, + } +} + +// traceLevelRows derives the ladder from http.TraceConfigForLogLevel instead of +// transcribing it, so the table cannot drift from the code and reflects an +// HTTP_LOG_BASE_LEVEL / http.log.base-level override at render time. Levels that +// capture nothing new are folded away, and rungs that only extend the one below +// them are rendered as the delta. +func traceLevelRows() []traceLevelRow { + var rows []traceLevelRow + var previous []string + for level := logger.Warn; level <= logger.Trace4; level++ { + captures := traceCaptures(http.TraceConfigForLogLevel(level)) + if len(rows) > 0 && slices.Equal(previous, captures) { + continue + } + rows = append(rows, traceLevelRow{ + Level: level, + Flag: levelFlag(level), + Captures: describeRung(captures, previous), + }) + previous = captures + } + return rows +} + +// traceCaptures lists everything a level captures, in ladder order. +func traceCaptures(config http.TraceConfig, enabled bool) []string { + if !enabled { + return nil + } + if config.AccessLogErrorsOnly { + return []string{"failed requests only (errors and status >= 400)"} + } + + captures := []string{"one access line per request"} + if config.Headers || config.ResponseHeaders { + captures = append(captures, "headers") + } + if config.QueryParam || config.FormParams { + captures = append(captures, "query and form params") + } + if config.Body { + captures = append(captures, "request bodies") + } + if config.TLS { + captures = append(captures, "TLS summary") + } + if config.Response { + captures = append(captures, "response bodies") + } + return captures +} + +// describeRung renders captures relative to the rung below it, so each row shows +// what the extra verbosity buys rather than repeating the whole list. +func describeRung(captures, previous []string) string { + switch { + case len(captures) == 0: + return "nothing" + case len(previous) == 0 || !slices.Equal(previous, captures[:min(len(previous), len(captures))]): + return strings.Join(captures, ", ") + default: + return "+ " + strings.Join(captures[len(previous):], ", ") + } +} + +// levelFlag renders how a level is reached from the command line. logger.Configure +// passes the -v count straight to SetLogLevel, so the count is the level number. +func levelFlag(level logger.LogLevel) string { + if level <= logger.Info { + return "--log-level=" + level.String() + } + return "-" + strings.Repeat("v", int(level)) +} diff --git a/help/logging.go b/help/logging.go new file mode 100644 index 0000000..3654e7f --- /dev/null +++ b/help/logging.go @@ -0,0 +1,33 @@ +package help + +import "github.com/flanksource/clicky/api" + +func propertiesTopic() api.Text { + return lines( + note("Every log.* and http.* setting below is a property. Properties are set with"), + note("-P/--properties, which is repeatable and also accepts comma separated pairs:"), + example("-Plog.level.http=trace,http.body.disabled=true"), + ) +} + +func loggingTopic() api.Text { + return lines( + knob("-v, -vv, -vvv, -vvvv", "raise the global level to debug, trace, trace1, trace2"), + knob("--log-level=", "error, warn, info (default), debug, trace, trace1..trace4"), + knob("LOG_LEVEL=", "the same levels, from the environment"), + knob("-Plog.level.=", "raise a single subsystem, e.g. log.level.http=trace"), + note("A -v count wins over --log-level. Named loggers inherit the global level unless"), + note("log.level. overrides them, so one subsystem can be traced without the"), + note("rest of the process becoming verbose."), + ) +} + +func logFormatTopic() api.Text { + return lines( + knob("--json-logs", "structured JSON on stderr instead of console text"), + knob("--report-caller", "prefix each line with its source file and line"), + knob("--color=false / --no-color", "disable ANSI colour (whichever flag the CLI binds)"), + knob("NO_COLOR, COLOR=no, TERM=dumb", "disable ANSI colour from the environment"), + note("Logs always go to stderr; --log-to-stderr is accepted but ignored."), + ) +}