From 6bfef0a9f2670f6c0507e87c86cfa77e31d8d97c Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Wed, 30 Sep 2026 16:40:38 -0700
Subject: [PATCH 01/13] Add Micronaut metrics collector and shared
normalization (#56)
---
internal/collector/micrometer.go | 261 ++++++++++++++++
internal/collector/micronaut.go | 214 +++++++++++++
internal/collector/micronaut_test.go | 444 +++++++++++++++++++++++++++
internal/collector/quarkus.go | 276 +----------------
internal/collector/quarkus_test.go | 78 ++++-
5 files changed, 1009 insertions(+), 264 deletions(-)
create mode 100644 internal/collector/micrometer.go
create mode 100644 internal/collector/micronaut.go
create mode 100644 internal/collector/micronaut_test.go
diff --git a/internal/collector/micrometer.go b/internal/collector/micrometer.go
new file mode 100644
index 0000000..5e656cb
--- /dev/null
+++ b/internal/collector/micrometer.go
@@ -0,0 +1,261 @@
+// Bounded normalization shared by explicit Micrometer framework adapters.
+package collector
+
+import (
+ "hash/fnv"
+ "math/bits"
+ "time"
+
+ "github.com/pvrlabs/statlite/internal/prometheus"
+)
+
+// micrometerHTTPLabelValidator returns the framework-validated numeric status.
+// Framework adapters own required labels and accepted status syntax.
+type micrometerHTTPLabelValidator func(prometheus.Sample) (int, bool)
+
+// micrometerHTTPValues retains fixed-size StatLite aggregates plus a bounded set
+// of label fingerprints used only to match timer count and sum populations.
+// Source labels and series are inspected while streaming and are not retained.
+type micrometerHTTPValues struct {
+ requests float64
+ durationSeconds float64
+ notFound float64
+ clientErrors float64
+ serverErrors float64
+ sawCount bool
+ sawDuration bool
+ incompleteCountLabel bool
+ incompleteSumLabel bool
+ countDimensions map[micrometerHTTPDimensionHash]int
+ durationDimensions map[micrometerHTTPDimensionHash]int
+ matchingStates int
+ matchingOverflow bool
+ requestOverflow bool
+ notFoundOverflow bool
+ clientErrorsOverflow bool
+ serverErrorsOverflow bool
+ durationOverflow bool
+ countDuplicate bool
+ durationDuplicate bool
+}
+
+type micrometerHTTPDimensionHash struct{ first, second uint64 }
+
+type micrometerRuntimeValues struct {
+ cpu, heap, processStart, uptime float64
+ sawCPU, sawHeap, sawProcessStart, sawUptime bool
+ invalidCPU, invalidHeap, invalidProcessStart, invalidUptime bool
+}
+
+// Count and sum share a fixed budget of transient identities, independent of
+// the parser's sample limit. Each population consumes this combined budget.
+const micrometerHTTPMatchingStateLimit = 20_000
+
+func (v *micrometerRuntimeValues) acceptCPU(value float64) bool {
+ if v.sawCPU || !finiteInRange(value, 0, 1) {
+ v.invalidCPU = true
+ return false
+ }
+ v.sawCPU, v.cpu = true, value
+ return true
+}
+
+func (v *micrometerRuntimeValues) acceptHeap(value float64) bool {
+ if !finiteNonnegative(value) {
+ v.invalidHeap = true
+ return false
+ }
+ var ok bool
+ v.heap, ok = addFiniteNonnegative(v.heap, value)
+ v.sawHeap = true
+ v.invalidHeap = v.invalidHeap || !ok
+ return ok
+}
+
+func (v *micrometerRuntimeValues) acceptProcessStart(value float64) bool {
+ if v.sawProcessStart || !finiteNonnegative(value) || value == 0 || !rfc3339RoundTripsUnixSeconds(value) {
+ v.invalidProcessStart = true
+ return false
+ }
+ v.sawProcessStart, v.processStart = true, value
+ return true
+}
+
+func rfc3339RoundTripsUnixSeconds(value float64) bool {
+ converted := unixSeconds(value)
+ formatted := converted.Format(time.RFC3339Nano)
+ parsed, err := time.Parse(time.RFC3339Nano, formatted)
+ return err == nil && parsed.Equal(converted)
+}
+
+func (v *micrometerRuntimeValues) acceptUptime(value float64) {
+ if v.sawUptime || !finiteNonnegative(value) {
+ v.invalidUptime = true
+ return
+ }
+ v.sawUptime, v.uptime = true, value
+}
+
+func (v micrometerRuntimeValues) addTo(result *CollectionResult) {
+ if v.sawCPU && !v.invalidCPU {
+ result.addSample("process_cpu_usage", MetricKindGauge, v.cpu, "ratio")
+ }
+ if v.sawHeap && !v.invalidHeap {
+ result.addSample("jvm_heap_used_bytes", MetricKindGauge, v.heap, "bytes")
+ }
+ if v.sawProcessStart && !v.invalidProcessStart {
+ result.addSample("process_start_time", MetricKindGauge, v.processStart, "unix_seconds")
+ started := unixSeconds(v.processStart)
+ result.ProcessStartTime = &started
+ }
+ if v.sawUptime && !v.invalidUptime {
+ result.addSample("process_uptime", MetricKindGauge, v.uptime, "seconds")
+ }
+}
+
+func (v *micrometerHTTPValues) acceptCount(sample prometheus.Sample, validate micrometerHTTPLabelValidator) {
+ code, ok := validate(sample)
+ if !ok || !finiteNonnegative(sample.Value) {
+ v.incompleteCountLabel = true
+ return
+ }
+ dimension := micrometerHTTPSeriesFingerprint(sample.Labels)
+ if v.countDimensions[dimension] > 0 {
+ v.countDuplicate = true
+ return
+ }
+ v.sawCount = true
+ var added bool
+ v.requests, added = addFiniteNonnegative(v.requests, sample.Value)
+ if !added {
+ v.requestOverflow = true
+ }
+ v.addMatchingDimension(&v.countDimensions, dimension)
+ if code == 404 {
+ v.notFound, added = addFiniteNonnegative(v.notFound, sample.Value)
+ v.notFoundOverflow = v.notFoundOverflow || !added
+ }
+ if code >= 400 && code <= 499 {
+ v.clientErrors, added = addFiniteNonnegative(v.clientErrors, sample.Value)
+ v.clientErrorsOverflow = v.clientErrorsOverflow || !added
+ }
+ if code >= 500 {
+ v.serverErrors, added = addFiniteNonnegative(v.serverErrors, sample.Value)
+ v.serverErrorsOverflow = v.serverErrorsOverflow || !added
+ }
+}
+
+func (v *micrometerHTTPValues) acceptDuration(sample prometheus.Sample, validate micrometerHTTPLabelValidator) {
+ _, ok := validate(sample)
+ if !ok || !finiteNonnegative(sample.Value) {
+ v.incompleteSumLabel = true
+ return
+ }
+ dimension := micrometerHTTPSeriesFingerprint(sample.Labels)
+ if v.durationDimensions[dimension] > 0 {
+ v.durationDuplicate = true
+ return
+ }
+ v.sawDuration = true
+ var added bool
+ v.durationSeconds, added = addFiniteNonnegative(v.durationSeconds, sample.Value)
+ if !added {
+ v.durationOverflow = true
+ }
+ v.addMatchingDimension(&v.durationDimensions, dimension)
+}
+
+func (v *micrometerHTTPValues) addMatchingDimension(groups *map[micrometerHTTPDimensionHash]int, key micrometerHTTPDimensionHash) {
+ if v.matchingOverflow {
+ return
+ }
+ if *groups == nil {
+ *groups = make(map[micrometerHTTPDimensionHash]int)
+ }
+ if _, exists := (*groups)[key]; !exists {
+ if v.matchingStates == micrometerHTTPMatchingStateLimit {
+ v.matchingOverflow = true
+ return
+ }
+ v.matchingStates++
+ }
+ (*groups)[key]++
+}
+
+func micrometerHTTPSeriesFingerprint(labels []prometheus.Label) micrometerHTTPDimensionHash {
+ // Label order is not part of Prometheus series identity. Combine hashes of
+ // every bounded name/value pair commutatively so count and sum still match
+ // when exposition order differs, without retaining any raw label.
+ var fingerprint micrometerHTTPDimensionHash
+ for _, label := range labels {
+ first := hashHTTPDimensions(fnv.New64a(), label.Name, label.Value)
+ second := hashHTTPDimensions(fnv.New64(), label.Name, label.Value)
+ fingerprint.first += first
+ fingerprint.second += second ^ bits.RotateLeft64(first, 23)
+ }
+ fingerprint.first ^= uint64(len(labels)) * 0x9e3779b97f4a7c15
+ fingerprint.second ^= uint64(len(labels)) * 0xc2b2ae3d27d4eb4f
+ return fingerprint
+}
+
+func hashHTTPDimensions(hash interface {
+ Write([]byte) (int, error)
+ Sum64() uint64
+}, values ...string) uint64 {
+ for _, value := range values {
+ _, _ = hash.Write([]byte{0})
+ _, _ = hash.Write([]byte(value))
+ }
+ return hash.Sum64()
+}
+
+func (v *micrometerHTTPValues) durationMatchesCounts() bool {
+ if v.matchingOverflow || v.durationOverflow || v.countDuplicate || v.durationDuplicate || !v.sawCount || !v.sawDuration || len(v.countDimensions) != len(v.durationDimensions) {
+ return false
+ }
+ for key, countSeries := range v.countDimensions {
+ if v.durationDimensions[key] != countSeries {
+ return false
+ }
+ }
+ return true
+}
+
+func (v *micrometerHTTPValues) addTo(result *CollectionResult) {
+ if v.sawCount && !v.requestOverflow && !v.countDuplicate && !v.matchingOverflow {
+ result.addSample("http_requests_total", MetricKindCounter, v.requests, "requests")
+ }
+ if v.sawCount && !v.incompleteCountLabel && !v.countDuplicate && !v.matchingOverflow {
+ if !v.notFoundOverflow {
+ result.addSample("http_404_total", MetricKindCounter, v.notFound, "requests")
+ }
+ if !v.clientErrorsOverflow {
+ result.addSample("http_4xx_total", MetricKindCounter, v.clientErrors, "requests")
+ }
+ if !v.serverErrorsOverflow {
+ result.addSample("http_5xx_total", MetricKindCounter, v.serverErrors, "requests")
+ }
+ }
+ durationMatches := v.durationMatchesCounts()
+ if durationMatches {
+ result.addSample("http_request_time_total_seconds", MetricKindCounter, v.durationSeconds, "seconds")
+ }
+}
+
+func (v *micrometerRuntimeValues) accept(sample prometheus.Sample) {
+ switch sample.Name {
+ case "process_cpu_usage":
+ v.acceptCPU(sample.Value)
+ case "process_start_time_seconds":
+ v.acceptProcessStart(sample.Value)
+ case "process_uptime_seconds":
+ v.acceptUptime(sample.Value)
+ case "jvm_memory_used_bytes":
+ for _, label := range sample.Labels {
+ if label.Name == "area" && label.Value == "heap" {
+ v.acceptHeap(sample.Value)
+ break
+ }
+ }
+ }
+}
diff --git a/internal/collector/micronaut.go b/internal/collector/micronaut.go
new file mode 100644
index 0000000..e842b47
--- /dev/null
+++ b/internal/collector/micronaut.go
@@ -0,0 +1,214 @@
+// This file validates and normalizes the explicitly selected Micronaut contract.
+package collector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/pvrlabs/statlite/internal/prometheus"
+)
+
+// MicronautCollector performs one bounded scrape of the exact metrics endpoint.
+type MicronautCollector struct {
+ targetName string
+ endpoint string
+ client *prometheus.Client
+}
+
+var ErrMicronautIncompatible = errors.New("Micronaut metrics endpoint does not expose a finite recognized runtime family")
+
+// MicronautInspection describes supported concepts, not proven framework origin.
+type MicronautInspection struct {
+ Status string
+ Capabilities []string
+ Warnings []string
+}
+
+type micronautEvaluation struct {
+ samples []MetricSample
+ events []CollectorEvent
+ processStartTime *time.Time
+}
+
+func NewMicronautCollector(targetName, endpoint string, client *prometheus.Client) *MicronautCollector {
+ return &MicronautCollector{targetName: targetName, endpoint: endpoint, client: client}
+}
+
+func InspectMicronaut(ctx context.Context, endpoint string, client *prometheus.Client) (*MicronautInspection, error) {
+ evaluation, err := NewMicronautCollector("", endpoint, client).evaluate(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ inspection := &MicronautInspection{Status: "compatible"}
+ for _, sample := range evaluation.samples {
+ inspection.Capabilities = append(inspection.Capabilities, sample.Key)
+ }
+ for _, event := range evaluation.events {
+ if event.Severity == EventSeverityWarning {
+ inspection.Warnings = append(inspection.Warnings, event.Message)
+ }
+ }
+ if len(inspection.Warnings) > 0 {
+ inspection.Status = "partial"
+ }
+ return inspection, nil
+}
+
+func (c *MicronautCollector) Collect(ctx context.Context) (*CollectionResult, error) {
+ result := &CollectionResult{TargetName: c.targetName, PollStartedAt: time.Now().UTC()}
+ defer func() { result.PollFinishedAt = time.Now().UTC() }()
+ if c.client == nil || c.endpoint == "" {
+ err := errors.New("Micronaut metrics client is not configured")
+ result.addEvent(EventSeverityError, "collector_not_configured", "", err.Error())
+ return result, err
+ }
+ evaluation, err := c.evaluate(ctx)
+ if err != nil {
+ if errors.Is(err, ErrMicronautIncompatible) {
+ result.addEvent(EventSeverityError, "metrics_source_incompatible", "", err.Error())
+ } else {
+ result.addEvent(EventSeverityError, "metrics_fetch_failed", "", err.Error())
+ }
+ return result, err
+ }
+ result.Samples = evaluation.samples
+ result.Events = evaluation.events
+ result.ProcessStartTime = evaluation.processStartTime
+ return result, nil
+}
+
+func (c *MicronautCollector) evaluate(ctx context.Context) (*micronautEvaluation, error) {
+ if c.client == nil || c.endpoint == "" {
+ return nil, errors.New("Micronaut metrics client is not configured")
+ }
+ httpValues := micrometerHTTPValues{}
+ runtimeValues := micrometerRuntimeValues{}
+ _, err := c.client.Scrape(ctx, c.endpoint, func(sample prometheus.Sample) error {
+ switch sample.Name {
+ case "http_server_requests_seconds_count":
+ httpValues.acceptCount(sample, validateMicronautHTTPLabels)
+ return nil
+ case "http_server_requests_seconds_sum":
+ httpValues.acceptDuration(sample, validateMicronautHTTPLabels)
+ return nil
+ }
+ runtimeValues.accept(sample)
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scraping Micronaut metrics: %w", err)
+ }
+ if !micronautRuntimeCompatible(runtimeValues) {
+ err := fmt.Errorf("%w", ErrMicronautIncompatible)
+ return nil, err
+ }
+ normalized := &CollectionResult{}
+ httpValues.addTo(normalized)
+ addMicronautHTTPWarnings(normalized, &httpValues)
+ runtimeValues.addTo(normalized)
+ addMicronautPartialWarning(normalized, runtimeValues)
+ return µnautEvaluation{
+ samples: normalized.Samples,
+ events: normalized.Events,
+ processStartTime: normalized.ProcessStartTime,
+ }, nil
+}
+
+func micronautRuntimeCompatible(v micrometerRuntimeValues) bool {
+ return (v.sawCPU && !v.invalidCPU) || (v.sawHeap && !v.invalidHeap) || (v.sawProcessStart && !v.invalidProcessStart)
+}
+
+func addMicronautPartialWarning(result *CollectionResult, runtime micrometerRuntimeValues) {
+ invalid := make([]string, 0, 4)
+ if runtime.invalidCPU {
+ invalid = append(invalid, "process CPU")
+ }
+ if runtime.invalidHeap {
+ invalid = append(invalid, "heap used")
+ }
+ if runtime.invalidProcessStart {
+ invalid = append(invalid, "process start time")
+ }
+ if runtime.invalidUptime {
+ invalid = append(invalid, "process uptime")
+ }
+ if len(invalid) == 0 {
+ return
+ }
+ slices.Sort(invalid)
+ result.addEvent(EventSeverityWarning, "metrics_partial", "", "Micronaut metrics are partial; invalid concepts were omitted: "+strings.Join(invalid, ", "))
+}
+
+// No trimming, enums, or Quarkus outcome requirement: additional labels remain
+// part of transient series identity, but never become normalized dimensions.
+func validateMicronautHTTPLabels(sample prometheus.Sample) (int, bool) {
+ var method, status, uri, exception string
+ for _, label := range sample.Labels {
+ switch label.Name {
+ case "method":
+ method = label.Value
+ case "status":
+ status = label.Value
+ case "uri":
+ uri = label.Value
+ case "exception":
+ exception = label.Value
+ }
+ }
+ if method == "" || uri == "" || exception == "" || len(status) != 3 {
+ return 0, false
+ }
+ code := 0
+ for i := 0; i < len(status); i++ {
+ if status[i] < '0' || status[i] > '9' {
+ return 0, false
+ }
+ code = code*10 + int(status[i]-'0')
+ }
+ return code, code >= 100 && code <= 599
+}
+
+func addMicronautHTTPWarnings(result *CollectionResult, v *micrometerHTTPValues) {
+ durationMatches := v.durationMatchesCounts()
+ if v.incompleteCountLabel {
+ result.addEvent(EventSeverityWarning, "metric_dimension_invalid", "http_requests_total", "Micronaut metrics: ignored http_server_requests_seconds_count series without valid method, status, uri, and exception dimensions and a finite nonnegative value; status counters are unavailable")
+ }
+ if v.incompleteSumLabel {
+ result.addEvent(EventSeverityWarning, "metric_dimension_invalid", "http_request_time_total_seconds", "Micronaut metrics: ignored http_server_requests_seconds_sum series without valid method, status, uri, and exception dimensions and a finite nonnegative value")
+ }
+ if v.sawDuration && !durationMatches && !v.durationOverflow && !v.countDuplicate && !v.durationDuplicate && !v.matchingOverflow {
+ result.addEvent(EventSeverityWarning, "metric_series_mismatch", "http_request_time_total_seconds", "Micronaut metrics: omitted HTTP request duration because its accepted dimensions do not match request count series within the bounded matching state")
+ }
+ if v.requestOverflow {
+ result.addEvent(EventSeverityWarning, "metric_aggregate_invalid", "http_requests_total", "Micronaut metrics: omitted HTTP request count because finite source values overflowed the normalized aggregate")
+ }
+ for _, status := range []struct {
+ key string
+ overflow bool
+ }{
+ {key: "http_404_total", overflow: v.notFoundOverflow},
+ {key: "http_4xx_total", overflow: v.clientErrorsOverflow},
+ {key: "http_5xx_total", overflow: v.serverErrorsOverflow},
+ } {
+ if status.overflow {
+ result.addEvent(EventSeverityWarning, "metric_aggregate_invalid", status.key, fmt.Sprintf("Micronaut metrics: omitted %s because finite source values overflowed the normalized aggregate", status.key))
+ }
+ }
+ if v.durationOverflow {
+ result.addEvent(EventSeverityWarning, "metric_aggregate_invalid", "http_request_time_total_seconds", "Micronaut metrics: omitted HTTP request duration because finite source values overflowed the normalized aggregate")
+ }
+ if v.countDuplicate {
+ result.addEvent(EventSeverityWarning, "metric_series_duplicate", "http_requests_total", "Micronaut metrics: omitted HTTP request and status counters because a count series identity was repeated")
+ }
+ if v.durationDuplicate {
+ result.addEvent(EventSeverityWarning, "metric_series_duplicate", "http_request_time_total_seconds", "Micronaut metrics: omitted HTTP request duration because a duration series identity was repeated")
+ }
+ if v.matchingOverflow {
+ result.addEvent(EventSeverityWarning, "metric_aggregation_limit", "", "Micronaut metrics: omitted HTTP request, status, and duration concepts because bounded series-identity state was exceeded")
+ }
+}
diff --git a/internal/collector/micronaut_test.go b/internal/collector/micronaut_test.go
new file mode 100644
index 0000000..d1758cf
--- /dev/null
+++ b/internal/collector/micronaut_test.go
@@ -0,0 +1,444 @@
+package collector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/pvrlabs/statlite/internal/prometheus"
+)
+
+const micronautTestLabels = `method="GET",status="200",uri="/probe/ok",exception="none"`
+
+func micronautTimer(labels, count, sum string) string {
+ var body string
+ if count != "" {
+ body += "http_server_requests_seconds_count{" + labels + "} " + count + "\n"
+ }
+ if sum != "" {
+ body += "http_server_requests_seconds_sum{" + labels + "} " + sum + "\n"
+ }
+ return body
+}
+
+func newTestMicronautCollector(t *testing.T, handler http.HandlerFunc) *MicronautCollector {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+ client, err := prometheus.NewClient(time.Second, prometheus.DefaultLimits, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return NewMicronautCollector("orders", server.URL+"/prometheus", client)
+}
+
+func micronautBodyCollector(t *testing.T, body string) *MicronautCollector {
+ t.Helper()
+ return newTestMicronautCollector(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = w.Write([]byte(body))
+ })
+}
+
+func collectMicronautBody(t *testing.T, body string) *CollectionResult {
+ t.Helper()
+ c := micronautBodyCollector(t, body)
+ result, err := c.Collect(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.HealthStatus != "" || result.DBHealthStatus != "" {
+ t.Fatalf("fabricated health: %#v", result)
+ }
+ return result
+}
+
+func assertMicronautEvents(t *testing.T, result *CollectionResult, want ...string) {
+ t.Helper()
+ var got []string
+ for _, event := range result.Events {
+ got = append(got, event.Type+":"+event.MetricKey)
+ if event.Severity != EventSeverityWarning || !strings.Contains(event.Message, "Micronaut") || strings.Contains(event.Message, "Quarkus") {
+ t.Fatalf("event = %#v", event)
+ }
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("events = %v, want %v", got, want)
+ }
+}
+
+func TestMicronautCollectorIdleTrafficRestartShapes(t *testing.T) {
+ // Representative shapes from the pinned framework captures: no timers on
+ // first idle/restart scrape, four HTTP labels, heap pools and management traffic.
+ runtime := func(start, uptime string) string {
+ return `process_cpu_usage 0.25
+jvm_memory_used_bytes{area="heap",id="G1 Eden Space"} 1000
+jvm_memory_used_bytes{area="heap",id="G1 Old Gen"} 2000
+jvm_memory_used_bytes{area="heap",id="G1 Survivor Space"} 500
+jvm_memory_used_bytes{area="nonheap",id="Metaspace"} 9000
+process_start_time_seconds ` + start + "\nprocess_uptime_seconds " + uptime + "\n"
+ }
+ idle := runtime("1770000000.5", "7.5")
+ traffic := runtime("1770000000.5", "90") +
+ micronautTimer(micronautTestLabels, "3", "0.25") +
+ micronautTimer(`exception="none",method="GET",status="200",uri="/prometheus"`, "3", "0.5") +
+ micronautTimer(`exception="NotFoundException",method="GET",status="404",uri="NOT_FOUND"`, "1", "0.125") +
+ micronautTimer(`exception="none",method="GET",status="400",uri="/probe/bad"`, "1", "0.0625") +
+ micronautTimer(`exception="none",method="GET",status="500",uri="/probe/error"`, "1", "0.0625") +
+ `http_server_requests_seconds_bucket{le="+Inf"} 999
+http_server_requests_seconds{quantile="0.99"} 999
+http_server_requests_seconds_max 999
+unrelated_metric{status="500"} 999
+`
+ bodies := []string{idle, traffic, runtime("1770000100.5", "2")}
+ var requests atomic.Int32
+ c := newTestMicronautCollector(t, func(w http.ResponseWriter, r *http.Request) {
+ n := int(requests.Add(1)) - 1
+ if n >= len(bodies) {
+ t.Errorf("unexpected extra request")
+ http.Error(w, "extra", 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = w.Write([]byte(bodies[n]))
+ })
+ for i := range bodies {
+ result, err := c.Collect(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.TargetName != "orders" || result.PollStartedAt.IsZero() || result.PollFinishedAt.Before(result.PollStartedAt) || result.HealthStatus != "" || result.DBHealthStatus != "" {
+ t.Fatalf("poll = %#v", result)
+ }
+ assertMicronautEvents(t, result)
+ expectedStart, expectedUptime := 1770000000.5, 7.5
+ if i == 1 {
+ expectedUptime = 90
+ }
+ if i == 2 {
+ expectedStart, expectedUptime = 1770000100.5, 2
+ }
+ want := []MetricSample{}
+ if i == 1 {
+ want = append(want,
+ MetricSample{Key: "http_requests_total", Kind: MetricKindCounter, Value: 9, Unit: "requests"},
+ MetricSample{Key: "http_404_total", Kind: MetricKindCounter, Value: 1, Unit: "requests"},
+ MetricSample{Key: "http_4xx_total", Kind: MetricKindCounter, Value: 2, Unit: "requests"},
+ MetricSample{Key: "http_5xx_total", Kind: MetricKindCounter, Value: 1, Unit: "requests"},
+ MetricSample{Key: "http_request_time_total_seconds", Kind: MetricKindCounter, Value: 1, Unit: "seconds"})
+ }
+ want = append(want,
+ MetricSample{Key: "process_cpu_usage", Kind: MetricKindGauge, Value: 0.25, Unit: "ratio"},
+ MetricSample{Key: "jvm_heap_used_bytes", Kind: MetricKindGauge, Value: 3500, Unit: "bytes"},
+ MetricSample{Key: "process_start_time", Kind: MetricKindGauge, Value: expectedStart, Unit: "unix_seconds"},
+ MetricSample{Key: "process_uptime", Kind: MetricKindGauge, Value: expectedUptime, Unit: "seconds"})
+ if !reflect.DeepEqual(result.Samples, want) {
+ t.Fatalf("poll %d samples = %#v, want %#v", i, result.Samples, want)
+ }
+ if result.ProcessStartTime == nil || !result.ProcessStartTime.Equal(unixSeconds(expectedStart)) {
+ t.Fatalf("start = %v", result.ProcessStartTime)
+ }
+ }
+ if requests.Load() != 3 {
+ t.Fatalf("requests = %d", requests.Load())
+ }
+}
+
+func TestMicronautCollectorStrictHTTPLabels(t *testing.T) {
+ var invalid []string
+ for _, label := range []string{`method="GET"`, `status="200"`, `uri="/probe/ok"`, `exception="none"`} {
+ invalid = append(invalid, strings.Trim(strings.Replace(micronautTestLabels, label, "", 1), ","))
+ // Remove the interior empty separator for missing middle labels.
+ invalid[len(invalid)-1] = strings.ReplaceAll(invalid[len(invalid)-1], ",,", ",")
+ invalid = append(invalid, strings.Replace(micronautTestLabels, label, strings.Split(label, "=")[0]+`=""`, 1))
+ }
+ for _, status := range []string{"099", "600", "+200", "0200", " 200", "200 ", "200.0", "UNKNOWN", "200", "20", "2e2", "-200"} {
+ invalid = append(invalid, strings.Replace(micronautTestLabels, `status="200"`, `status="`+status+`"`, 1))
+ }
+ for _, labels := range invalid {
+ t.Run(labels, func(t *testing.T) {
+ result := collectMicronautBody(t, "process_cpu_usage 0.1\n"+micronautTimer(micronautTestLabels, "3", "2")+micronautTimer(labels, "9", "9"))
+ if got := strings.Join(sampleKeys(result.Samples), ","); got != "http_requests_total,http_request_time_total_seconds,process_cpu_usage" {
+ t.Fatalf("keys = %s", got)
+ }
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 3, "requests")
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 2, "seconds")
+ assertMicronautEvents(t, result, "metric_dimension_invalid:http_requests_total", "metric_dimension_invalid:http_request_time_total_seconds")
+ })
+ }
+ // Any nonempty method, route, or exception is allowed, without rewriting.
+ result := collectMicronautBody(t, "process_cpu_usage 0.1\n"+micronautTimer(`method="CUSTOM verb",status="200",uri=" ",exception="custom exception"`, "0.5", "0"))
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 0.5, "requests")
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 0, "seconds")
+ assertMicronautEvents(t, result)
+}
+
+func TestMicronautCollectorExactStatusFolding(t *testing.T) {
+ for _, code := range []int{100, 199, 200, 299, 300, 399, 400, 403, 404, 405, 499, 500, 599} {
+ t.Run(strconv.Itoa(code), func(t *testing.T) {
+ labels := strings.Replace(micronautTestLabels, `status="200"`, fmt.Sprintf(`status="%d"`, code), 1)
+ result := collectMicronautBody(t, "process_cpu_usage 0\n"+micronautTimer(labels, "2", "0"))
+ expected404, expected4xx, expected5xx := 0.0, 0.0, 0.0
+ if code == 404 {
+ expected404 = 2
+ }
+ if code >= 400 && code < 500 {
+ expected4xx = 2
+ }
+ if code >= 500 {
+ expected5xx = 2
+ }
+ assertSample(t, result, "http_404_total", MetricKindCounter, expected404, "requests")
+ assertSample(t, result, "http_4xx_total", MetricKindCounter, expected4xx, "requests")
+ assertSample(t, result, "http_5xx_total", MetricKindCounter, expected5xx, "requests")
+ assertMicronautEvents(t, result)
+ })
+ }
+}
+
+func TestMicronautCollectorHTTPIdentityAndFailures(t *testing.T) {
+ other := micronautTestLabels + `,extra="other"`
+ reordered := `exception="none",uri="/probe/ok",status="200",method="GET"`
+ tests := []struct {
+ name, body, keys string
+ events []string
+ }{
+ {"reordered", micronautTimer(micronautTestLabels, "3", "") + micronautTimer(reordered, "", "2"), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,http_request_time_total_seconds,process_cpu_usage", nil},
+ {"extra label mismatch", micronautTimer(micronautTestLabels, "3", "") + micronautTimer(other, "", "2"), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,process_cpu_usage", []string{"metric_series_mismatch:http_request_time_total_seconds"}},
+ {"route mismatch", micronautTimer(micronautTestLabels, "3", "") + micronautTimer(strings.Replace(micronautTestLabels, "/probe/ok", "/other", 1), "", "2"), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,process_cpu_usage", []string{"metric_series_mismatch:http_request_time_total_seconds"}},
+ {"count only", micronautTimer(micronautTestLabels, "3", ""), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,process_cpu_usage", nil},
+ {"sum only", micronautTimer(micronautTestLabels, "", "2"), "process_cpu_usage", []string{"metric_series_mismatch:http_request_time_total_seconds"}},
+ {"count duplicate", micronautTimer(micronautTestLabels, "3", "2") + micronautTimer(reordered, "4", ""), "process_cpu_usage", []string{"metric_series_duplicate:http_requests_total"}},
+ {"sum duplicate", micronautTimer(micronautTestLabels, "3", "2") + micronautTimer(reordered, "", "4"), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,process_cpu_usage", []string{"metric_series_duplicate:http_request_time_total_seconds"}},
+ {"count overflow keeps duration", micronautTimer(micronautTestLabels, "1e308", "1") + micronautTimer(other, "1e308", "2"), "http_404_total,http_4xx_total,http_5xx_total,http_request_time_total_seconds,process_cpu_usage", []string{"metric_aggregate_invalid:http_requests_total"}},
+ {"sum overflow", micronautTimer(micronautTestLabels, "1", "1e308") + micronautTimer(other, "1", "1e308"), "http_requests_total,http_404_total,http_4xx_total,http_5xx_total,process_cpu_usage", []string{"metric_aggregate_invalid:http_request_time_total_seconds"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := collectMicronautBody(t, "process_cpu_usage 0.1\n"+tt.body)
+ if got := strings.Join(sampleKeys(result.Samples), ","); got != tt.keys {
+ t.Fatalf("keys = %s, want %s", got, tt.keys)
+ }
+ assertMicronautEvents(t, result, tt.events...)
+ if tt.name == "count overflow keeps duration" {
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 3, "seconds")
+ }
+ })
+ }
+ for _, code := range []string{"404", "499", "599"} {
+ t.Run("status overflow "+code, func(t *testing.T) {
+ labels := strings.Replace(micronautTestLabels, `status="200"`, `status="`+code+`"`, 1)
+ result := collectMicronautBody(t, "process_cpu_usage 0.1\n"+micronautTimer(labels, "1e308", "1")+micronautTimer(labels+`,extra="x"`, "1e308", "2"))
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 3, "seconds")
+ want := []string{"metric_aggregate_invalid:http_requests_total"}
+ if code == "404" {
+ want = append(want, "metric_aggregate_invalid:http_404_total")
+ }
+ if code == "404" || code == "499" {
+ want = append(want, "metric_aggregate_invalid:http_4xx_total")
+ } else {
+ want = append(want, "metric_aggregate_invalid:http_5xx_total")
+ }
+ assertMicronautEvents(t, result, want...)
+ for _, event := range result.Events {
+ if hasSample(result, event.MetricKey) {
+ t.Fatalf("overflowed sample retained: %s", event.MetricKey)
+ }
+ }
+ })
+ }
+ for _, value := range []string{"-1", "NaN", "+Inf", "-Inf"} {
+ t.Run("invalid value "+value, func(t *testing.T) {
+ result := collectMicronautBody(t, "process_cpu_usage 0.1\n"+micronautTimer(micronautTestLabels, "3", "2")+micronautTimer(other, value, value))
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 3, "requests")
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 2, "seconds")
+ if hasSample(result, "http_404_total") || hasSample(result, "http_4xx_total") || hasSample(result, "http_5xx_total") {
+ t.Fatal("invalid count must omit all status counters")
+ }
+ assertMicronautEvents(t, result, "metric_dimension_invalid:http_requests_total", "metric_dimension_invalid:http_request_time_total_seconds")
+ })
+ }
+}
+
+func TestMicronautCollectorRuntimeCompatibilityAndInspection(t *testing.T) {
+ tests := []struct{ name, body, keys, status string }{
+ {"cpu zero", "process_cpu_usage 0\n", "process_cpu_usage", "compatible"},
+ {"cpu one", "process_cpu_usage 1\n", "process_cpu_usage", "compatible"},
+ {"heap zero", `jvm_memory_used_bytes{area="heap"} 0` + "\n", "jvm_heap_used_bytes", "compatible"},
+ {"start only", "process_start_time_seconds 1770000000.5\n", "process_start_time", "compatible"},
+ {"uptime alone", "process_uptime_seconds 2\n", "", ""},
+ {"http alone", micronautTimer(micronautTestLabels, "1", "2"), "", ""},
+ {"unrelated", "unrelated 1\nsystem_cpu_usage 0.2\njvm_memory_used_bytes{area=\"nonheap\"} 1\n", "", ""},
+ {"empty", "", "", ""},
+ {"invalid runtime only", "process_cpu_usage NaN\njvm_memory_used_bytes{area=\"heap\"} -1\nprocess_start_time_seconds 0\nprocess_uptime_seconds 1\n", "", ""},
+ {"partial", "process_cpu_usage 1.1\nprocess_start_time_seconds 253402300800\nprocess_uptime_seconds -1\njvm_memory_used_bytes{area=\"heap\"} 100\n", "jvm_heap_used_bytes", "partial"},
+ {"duplicate cpu", "process_cpu_usage 0.1\nprocess_cpu_usage 0.2\nprocess_start_time_seconds 1770000000\n", "process_start_time", "partial"},
+ {"duplicate start", "process_start_time_seconds 1770000000\nprocess_start_time_seconds 1770000000\nprocess_cpu_usage 0.2\n", "process_cpu_usage", "partial"},
+ {"duplicate uptime", "process_uptime_seconds 1\nprocess_uptime_seconds 2\nprocess_cpu_usage 0.2\n", "process_cpu_usage", "partial"},
+ {"heap overflow", "jvm_memory_used_bytes{area=\"heap\",id=\"a\"} 1e308\njvm_memory_used_bytes{area=\"heap\",id=\"b\"} 1e308\nprocess_cpu_usage 0.2\n", "process_cpu_usage", "partial"},
+ {"invalid heap pool", "jvm_memory_used_bytes{area=\"heap\",id=\"a\"} 2\njvm_memory_used_bytes{area=\"heap\",id=\"b\"} NaN\nprocess_cpu_usage 0.2\n", "process_cpu_usage", "partial"},
+ {"partial HTTP", "process_cpu_usage 0.1\n" + micronautTimer(`method="GET",status="200"`, "1", "1"), "process_cpu_usage", "partial"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := micronautBodyCollector(t, tt.body)
+ result, err := c.Collect(context.Background())
+ inspection, inspectErr := InspectMicronaut(context.Background(), c.endpoint, c.client)
+ if tt.status == "" {
+ if !errors.Is(err, ErrMicronautIncompatible) || !errors.Is(inspectErr, ErrMicronautIncompatible) || inspection != nil {
+ t.Fatalf("errors = %v / %v, inspection = %#v", err, inspectErr, inspection)
+ }
+ if len(result.Samples) != 0 || result.ProcessStartTime != nil || len(result.Events) != 1 || result.Events[0].Type != "metrics_source_incompatible" {
+ t.Fatalf("result = %#v", result)
+ }
+ return
+ }
+ if err != nil || inspectErr != nil {
+ t.Fatalf("errors = %v / %v", err, inspectErr)
+ }
+ if got := strings.Join(sampleKeys(result.Samples), ","); got != tt.keys {
+ t.Fatalf("keys = %s, want %s", got, tt.keys)
+ }
+ if inspection.Status != tt.status || !reflect.DeepEqual(inspection.Capabilities, sampleKeys(result.Samples)) || len(inspection.Warnings) != len(result.Events) {
+ t.Fatalf("inspection = %#v, result = %#v", inspection, result)
+ }
+ for i, event := range result.Events {
+ if inspection.Warnings[i] != event.Message {
+ t.Fatalf("inspection and runtime warnings differ")
+ }
+ }
+ if tt.name == "partial" {
+ assertMicronautEvents(t, result, "metrics_partial:")
+ if result.Events[0].Message != "Micronaut metrics are partial; invalid concepts were omitted: process CPU, process start time, process uptime" {
+ t.Fatalf("warning = %s", result.Events[0].Message)
+ }
+ }
+ })
+ }
+ // Invalid optional runtime values preserve an independent compatible concept.
+ for _, metric := range []string{"process_cpu_usage", "process_start_time_seconds", "process_uptime_seconds", "jvm_memory_used_bytes{area=\"heap\"}"} {
+ for _, value := range []string{"-1", "NaN", "+Inf", "-Inf"} {
+ t.Run(metric+value, func(t *testing.T) {
+ support := "process_cpu_usage 0.1\n"
+ if metric == "process_cpu_usage" {
+ support = "jvm_memory_used_bytes{area=\"heap\"} 1\n"
+ }
+ result := collectMicronautBody(t, support+metric+" "+value+"\n")
+ if len(result.Samples) != 1 {
+ t.Fatalf("samples = %#v", result.Samples)
+ }
+ assertMicronautEvents(t, result, "metrics_partial:")
+ })
+ }
+ }
+}
+
+func TestMicronautCollectorScrapeFailuresDiscardTentativeSamples(t *testing.T) {
+ tests := []struct {
+ name, body string
+ status int
+ }{
+ {"duplicate label", "process_cpu_usage 0.1\n" + micronautTimer(micronautTestLabels+`,method="POST"`, "1", ""), 200},
+ {"malformed labels", "process_cpu_usage 0.1\nbroken{method=\"unterminated} 1\n", 200},
+ {"malformed value", "process_cpu_usage 0.1\nbroken nope\n", 200},
+ {"unauthorized", "process_cpu_usage 0.1\n", 401},
+ {"missing", "process_cpu_usage 0.1\n", 404},
+ {"oversized", "process_cpu_usage 0.1\n#" + strings.Repeat("x", 4*1024*1024) + "\n", 200},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var requests atomic.Int32
+ c := newTestMicronautCollector(t, func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ w.WriteHeader(tt.status)
+ _, _ = w.Write([]byte(tt.body))
+ })
+ result, err := c.Collect(context.Background())
+ if err == nil || errors.Is(err, ErrMicronautIncompatible) || len(result.Samples) != 0 || result.ProcessStartTime != nil || len(result.Events) != 1 || result.Events[0].Type != "metrics_fetch_failed" {
+ t.Fatalf("result = %#v, error = %v", result, err)
+ }
+ if requests.Load() != 1 || result.HealthStatus != "" || result.DBHealthStatus != "" {
+ t.Fatalf("requests = %d, result = %#v", requests.Load(), result)
+ }
+ inspection, err := InspectMicronaut(context.Background(), c.endpoint, c.client)
+ if err == nil || inspection != nil {
+ t.Fatalf("inspection = %#v, error = %v", inspection, err)
+ }
+ })
+ }
+}
+
+func TestMicronautCollectorExactEndpointAuthAndOpenMetrics(t *testing.T) {
+ var requests atomic.Int32
+ c := newTestMicronautCollector(t, func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ if r.URL.RequestURI() != "/svc%2Fwest/custom/?scope=app" {
+ t.Errorf("URI = %s", r.URL.RequestURI())
+ }
+ user, password, ok := r.BasicAuth()
+ if !ok || user != "reader" || password != "secret" {
+ t.Errorf("missing expected basic auth")
+ }
+ w.Header().Set("Content-Type", "application/openmetrics-text; version=1.0.0")
+ _, _ = w.Write([]byte("process_cpu_usage 0.1\n" + micronautTimer(micronautTestLabels, "1", "2") + "# EOF\n"))
+ })
+ c.endpoint = strings.TrimSuffix(c.endpoint, "/prometheus") + "/svc%2Fwest/custom/?scope=app"
+ client, err := prometheus.NewClient(time.Second, prometheus.DefaultLimits, &prometheus.BasicAuth{Username: "reader", Password: "secret"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.client = client
+ result, err := c.Collect(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 1, "requests")
+ if requests.Load() != 1 {
+ t.Fatalf("requests = %d", requests.Load())
+ }
+ assertMicronautEvents(t, result)
+}
+
+func TestMicronautCollectorNotConfigured(t *testing.T) {
+ client, err := prometheus.NewClient(time.Second, prometheus.DefaultLimits, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []*MicronautCollector{NewMicronautCollector("orders", "http://localhost/prometheus", nil), NewMicronautCollector("orders", "", client)} {
+ result, err := c.Collect(context.Background())
+ if err == nil || len(result.Events) != 1 || result.Events[0].Type != "collector_not_configured" || result.PollFinishedAt.IsZero() {
+ t.Fatalf("result = %#v, error = %v", result, err)
+ }
+ inspection, err := InspectMicronaut(context.Background(), c.endpoint, c.client)
+ if err == nil || inspection != nil {
+ t.Fatalf("inspection = %#v, error = %v", inspection, err)
+ }
+ }
+}
+
+func TestMicronautCollectorCombinedMatchingBound(t *testing.T) {
+ var body strings.Builder
+ body.WriteString("process_cpu_usage 0.1\n")
+ for i := 0; i < micrometerHTTPMatchingStateLimit/2; i++ {
+ body.WriteString(micronautTimer(micronautTestLabels+fmt.Sprintf(`,extra="%d"`, i), "1", "0.5"))
+ }
+ exact := collectMicronautBody(t, body.String())
+ assertSample(t, exact, "http_requests_total", MetricKindCounter, 10000, "requests")
+ assertSample(t, exact, "http_request_time_total_seconds", MetricKindCounter, 5000, "seconds")
+ assertMicronautEvents(t, exact)
+ body.WriteString(micronautTimer(micronautTestLabels+`,extra="overflow"`, "1", "0.5"))
+ overflow := collectMicronautBody(t, body.String())
+ if strings.Join(sampleKeys(overflow.Samples), ",") != "process_cpu_usage" {
+ t.Fatalf("samples = %#v", overflow.Samples)
+ }
+ assertMicronautEvents(t, overflow, "metric_aggregation_limit:")
+}
diff --git a/internal/collector/quarkus.go b/internal/collector/quarkus.go
index 2c08a36..7b6a39e 100644
--- a/internal/collector/quarkus.go
+++ b/internal/collector/quarkus.go
@@ -5,8 +5,6 @@ import (
"context"
"errors"
"fmt"
- "hash/fnv"
- "math/bits"
"slices"
"strconv"
"strings"
@@ -50,46 +48,6 @@ type quarkusEvaluation struct {
processStartTime *time.Time
}
-// quarkusHTTPValues retains fixed-size StatLite aggregates plus a bounded set
-// of label fingerprints used only to match timer count and sum populations.
-// Source labels and series are inspected while streaming and are not retained.
-type quarkusHTTPValues struct {
- requests float64
- durationSeconds float64
- notFound float64
- clientErrors float64
- serverErrors float64
- sawCount bool
- sawDuration bool
- completeCountLabels bool
- incompleteCountLabel bool
- incompleteSumLabel bool
- countDimensions map[quarkusHTTPDimensionHash]int
- durationDimensions map[quarkusHTTPDimensionHash]int
- matchingStates int
- matchingOverflow bool
- requestOverflow bool
- notFoundOverflow bool
- clientErrorsOverflow bool
- serverErrorsOverflow bool
- durationOverflow bool
- countDuplicate bool
- durationDuplicate bool
-}
-
-type quarkusHTTPDimensionHash struct{ first, second uint64 }
-
-type quarkusRuntimeValues struct {
- cpu, heap, processStart, uptime float64
- sawCPU, sawHeap, sawProcessStart, sawUptime bool
- invalidCPU, invalidHeap, invalidProcessStart, invalidUptime bool
-}
-
-// Count and sum each contribute at most the shared parser's default 10,000
-// aggregation identities. The combined cap remains fixed regardless of source
-// series count.
-const quarkusHTTPMatchingStateLimit = 20_000
-
func NewQuarkusCollector(targetName, endpoint string, client *prometheus.Client, healthClient *QuarkusHealthClient) *QuarkusCollector {
return &QuarkusCollector{targetName: targetName, endpoint: endpoint, client: client, healthClient: healthClient}
}
@@ -205,43 +163,30 @@ func (c *QuarkusCollector) evaluate(ctx context.Context) (*quarkusEvaluation, er
if c.client == nil || c.endpoint == "" {
return nil, errors.New("Quarkus metrics client is not configured")
}
- httpValues := quarkusHTTPValues{completeCountLabels: true}
- runtimeValues := quarkusRuntimeValues{}
+ httpValues := micrometerHTTPValues{}
+ runtimeValues := micrometerRuntimeValues{}
_, err := c.client.Scrape(ctx, c.endpoint, func(sample prometheus.Sample) error {
switch sample.Name {
case "http_server_requests_seconds_count":
- httpValues.acceptCount(sample)
+ httpValues.acceptCount(sample, validateQuarkusHTTPLabels)
return nil
case "http_server_requests_seconds_sum":
- httpValues.acceptDuration(sample)
+ httpValues.acceptDuration(sample, validateQuarkusHTTPLabels)
return nil
}
- switch sample.Name {
- case "process_cpu_usage":
- runtimeValues.acceptCPU(sample.Value)
- case "process_start_time_seconds":
- runtimeValues.acceptProcessStart(sample.Value)
- case "process_uptime_seconds":
- runtimeValues.acceptUptime(sample.Value)
- case "jvm_memory_used_bytes":
- for _, label := range sample.Labels {
- if label.Name == "area" && label.Value == "heap" {
- runtimeValues.acceptHeap(sample.Value)
- break
- }
- }
- }
+ runtimeValues.accept(sample)
return nil
})
if err != nil {
return nil, fmt.Errorf("scraping Quarkus metrics: %w", err)
}
- if !runtimeValues.compatible() {
+ if !quarkusRuntimeCompatible(runtimeValues) {
err := fmt.Errorf("%w", ErrQuarkusIncompatible)
return nil, err
}
normalized := &CollectionResult{}
httpValues.addTo(normalized)
+ addQuarkusHTTPWarnings(normalized, &httpValues)
runtimeValues.addTo(normalized)
addQuarkusPartialWarning(normalized, runtimeValues)
return &quarkusEvaluation{
@@ -251,73 +196,11 @@ func (c *QuarkusCollector) evaluate(ctx context.Context) (*quarkusEvaluation, er
}, nil
}
-func (v quarkusRuntimeValues) compatible() bool {
+func quarkusRuntimeCompatible(v micrometerRuntimeValues) bool {
return (v.sawCPU && !v.invalidCPU) || (v.sawHeap && !v.invalidHeap) || (v.sawProcessStart && !v.invalidProcessStart)
}
-func (v *quarkusRuntimeValues) acceptCPU(value float64) bool {
- if v.sawCPU || !finiteInRange(value, 0, 1) {
- v.invalidCPU = true
- return false
- }
- v.sawCPU, v.cpu = true, value
- return true
-}
-
-func (v *quarkusRuntimeValues) acceptHeap(value float64) bool {
- if !finiteNonnegative(value) {
- v.invalidHeap = true
- return false
- }
- var ok bool
- v.heap, ok = addFiniteNonnegative(v.heap, value)
- v.sawHeap = true
- v.invalidHeap = v.invalidHeap || !ok
- return ok
-}
-
-func (v *quarkusRuntimeValues) acceptProcessStart(value float64) bool {
- if v.sawProcessStart || !finiteNonnegative(value) || value == 0 || !rfc3339RoundTripsUnixSeconds(value) {
- v.invalidProcessStart = true
- return false
- }
- v.sawProcessStart, v.processStart = true, value
- return true
-}
-
-func rfc3339RoundTripsUnixSeconds(value float64) bool {
- converted := unixSeconds(value)
- formatted := converted.Format(time.RFC3339Nano)
- parsed, err := time.Parse(time.RFC3339Nano, formatted)
- return err == nil && parsed.Equal(converted)
-}
-
-func (v *quarkusRuntimeValues) acceptUptime(value float64) {
- if v.sawUptime || !finiteNonnegative(value) {
- v.invalidUptime = true
- return
- }
- v.sawUptime, v.uptime = true, value
-}
-
-func (v quarkusRuntimeValues) addTo(result *CollectionResult) {
- if v.sawCPU && !v.invalidCPU {
- result.addSample("process_cpu_usage", MetricKindGauge, v.cpu, "ratio")
- }
- if v.sawHeap && !v.invalidHeap {
- result.addSample("jvm_heap_used_bytes", MetricKindGauge, v.heap, "bytes")
- }
- if v.sawProcessStart && !v.invalidProcessStart {
- result.addSample("process_start_time", MetricKindGauge, v.processStart, "unix_seconds")
- started := unixSeconds(v.processStart)
- result.ProcessStartTime = &started
- }
- if v.sawUptime && !v.invalidUptime {
- result.addSample("process_uptime", MetricKindGauge, v.uptime, "seconds")
- }
-}
-
-func addQuarkusPartialWarning(result *CollectionResult, runtime quarkusRuntimeValues) {
+func addQuarkusPartialWarning(result *CollectionResult, runtime micrometerRuntimeValues) {
invalid := make([]string, 0, 4)
if runtime.invalidCPU {
invalid = append(invalid, "process CPU")
@@ -338,122 +221,6 @@ func addQuarkusPartialWarning(result *CollectionResult, runtime quarkusRuntimeVa
result.addEvent(EventSeverityWarning, "metrics_partial", "", "Quarkus metrics are partial; invalid concepts were omitted: "+strings.Join(invalid, ", "))
}
-func (v *quarkusHTTPValues) acceptCount(sample prometheus.Sample) {
- method, outcome, status, ok := quarkusHTTPDimensions(sample)
- if !ok || method == "" || outcome == "" || !finiteNonnegative(sample.Value) {
- v.completeCountLabels = false
- v.incompleteCountLabel = true
- return
- }
- code, err := strconv.Atoi(status)
- if err != nil || code < 100 || code > 599 {
- v.completeCountLabels = false
- v.incompleteCountLabel = true
- return
- }
- dimension := quarkusHTTPSeriesFingerprint(sample.Labels)
- if v.countDimensions[dimension] > 0 {
- v.countDuplicate = true
- return
- }
- v.sawCount = true
- var added bool
- v.requests, added = addFiniteNonnegative(v.requests, sample.Value)
- if !added {
- v.requestOverflow = true
- }
- v.addMatchingDimension(&v.countDimensions, dimension)
- if code == 404 {
- v.notFound, added = addFiniteNonnegative(v.notFound, sample.Value)
- v.notFoundOverflow = v.notFoundOverflow || !added
- }
- if code >= 400 && code <= 499 {
- v.clientErrors, added = addFiniteNonnegative(v.clientErrors, sample.Value)
- v.clientErrorsOverflow = v.clientErrorsOverflow || !added
- }
- if code >= 500 {
- v.serverErrors, added = addFiniteNonnegative(v.serverErrors, sample.Value)
- v.serverErrorsOverflow = v.serverErrorsOverflow || !added
- }
-}
-
-func (v *quarkusHTTPValues) acceptDuration(sample prometheus.Sample) {
- method, outcome, status, ok := quarkusHTTPDimensions(sample)
- code, err := strconv.Atoi(status)
- if !ok || method == "" || outcome == "" || err != nil || code < 100 || code > 599 || !finiteNonnegative(sample.Value) {
- v.incompleteSumLabel = true
- return
- }
- dimension := quarkusHTTPSeriesFingerprint(sample.Labels)
- if v.durationDimensions[dimension] > 0 {
- v.durationDuplicate = true
- return
- }
- v.sawDuration = true
- var added bool
- v.durationSeconds, added = addFiniteNonnegative(v.durationSeconds, sample.Value)
- if !added {
- v.durationOverflow = true
- }
- v.addMatchingDimension(&v.durationDimensions, dimension)
-}
-
-func (v *quarkusHTTPValues) addMatchingDimension(groups *map[quarkusHTTPDimensionHash]int, key quarkusHTTPDimensionHash) {
- if v.matchingOverflow {
- return
- }
- if *groups == nil {
- *groups = make(map[quarkusHTTPDimensionHash]int)
- }
- if _, exists := (*groups)[key]; !exists {
- if v.matchingStates == quarkusHTTPMatchingStateLimit {
- v.matchingOverflow = true
- return
- }
- v.matchingStates++
- }
- (*groups)[key]++
-}
-
-func quarkusHTTPSeriesFingerprint(labels []prometheus.Label) quarkusHTTPDimensionHash {
- // Label order is not part of Prometheus series identity. Combine hashes of
- // every bounded name/value pair commutatively so count and sum still match
- // when exposition order differs, without retaining any raw label.
- var fingerprint quarkusHTTPDimensionHash
- for _, label := range labels {
- first := hashHTTPDimensions(fnv.New64a(), label.Name, label.Value)
- second := hashHTTPDimensions(fnv.New64(), label.Name, label.Value)
- fingerprint.first += first
- fingerprint.second += second ^ bits.RotateLeft64(first, 23)
- }
- fingerprint.first ^= uint64(len(labels)) * 0x9e3779b97f4a7c15
- fingerprint.second ^= uint64(len(labels)) * 0xc2b2ae3d27d4eb4f
- return fingerprint
-}
-
-func hashHTTPDimensions(hash interface {
- Write([]byte) (int, error)
- Sum64() uint64
-}, values ...string) uint64 {
- for _, value := range values {
- _, _ = hash.Write([]byte{0})
- _, _ = hash.Write([]byte(value))
- }
- return hash.Sum64()
-}
-
-func (v *quarkusHTTPValues) durationMatchesCounts() bool {
- if v.matchingOverflow || v.durationOverflow || v.countDuplicate || v.durationDuplicate || !v.sawCount || !v.sawDuration || len(v.countDimensions) != len(v.durationDimensions) {
- return false
- }
- for key, countSeries := range v.countDimensions {
- if v.durationDimensions[key] != countSeries {
- return false
- }
- }
- return true
-}
-
func quarkusHTTPDimensions(sample prometheus.Sample) (method, outcome, status string, ok bool) {
var methodOK, outcomeOK, statusOK bool
for _, label := range sample.Labels {
@@ -469,25 +236,8 @@ func quarkusHTTPDimensions(sample prometheus.Sample) (method, outcome, status st
return method, outcome, status, methodOK && outcomeOK && statusOK
}
-func (v *quarkusHTTPValues) addTo(result *CollectionResult) {
- if v.sawCount && !v.requestOverflow && !v.countDuplicate && !v.matchingOverflow {
- result.addSample("http_requests_total", MetricKindCounter, v.requests, "requests")
- }
- if v.sawCount && v.completeCountLabels && !v.countDuplicate && !v.matchingOverflow {
- if !v.notFoundOverflow {
- result.addSample("http_404_total", MetricKindCounter, v.notFound, "requests")
- }
- if !v.clientErrorsOverflow {
- result.addSample("http_4xx_total", MetricKindCounter, v.clientErrors, "requests")
- }
- if !v.serverErrorsOverflow {
- result.addSample("http_5xx_total", MetricKindCounter, v.serverErrors, "requests")
- }
- }
+func addQuarkusHTTPWarnings(result *CollectionResult, v *micrometerHTTPValues) {
durationMatches := v.durationMatchesCounts()
- if durationMatches {
- result.addSample("http_request_time_total_seconds", MetricKindCounter, v.durationSeconds, "seconds")
- }
if v.incompleteCountLabel {
result.addEvent(EventSeverityWarning, "metric_dimension_invalid", "http_requests_total", "ignored http_server_requests_seconds_count series without valid method, outcome, and status dimensions and a finite nonnegative value; status counters are unavailable")
}
@@ -525,3 +275,9 @@ func (v *quarkusHTTPValues) addTo(result *CollectionResult) {
result.addEvent(EventSeverityWarning, "metric_aggregation_limit", "", "omitted HTTP request, status, and duration concepts because bounded series-identity state was exceeded")
}
}
+
+func validateQuarkusHTTPLabels(sample prometheus.Sample) (int, bool) {
+ method, outcome, status, ok := quarkusHTTPDimensions(sample)
+ code, err := strconv.Atoi(status)
+ return code, ok && method != "" && outcome != "" && err == nil && code >= 100 && code <= 599
+}
diff --git a/internal/collector/quarkus_test.go b/internal/collector/quarkus_test.go
index e8aed53..2625653 100644
--- a/internal/collector/quarkus_test.go
+++ b/internal/collector/quarkus_test.go
@@ -399,15 +399,15 @@ process_cpu_usage 0.1
}
func TestQuarkusHTTPDurationMatchingStateIsBounded(t *testing.T) {
- v := quarkusHTTPValues{completeCountLabels: true}
- for i := 0; i < quarkusHTTPMatchingStateLimit+100; i++ {
+ v := micrometerHTTPValues{}
+ for i := 0; i < micrometerHTTPMatchingStateLimit+100; i++ {
v.acceptCount(prometheus.Sample{
Name: "http_server_requests_seconds_count",
Value: 1,
Labels: []prometheus.Label{{Name: "method", Value: "METHOD_" + strconv.Itoa(i)}, {Name: "outcome", Value: "SUCCESS"}, {Name: "status", Value: "200"}},
- })
+ }, validateQuarkusHTTPLabels)
}
- if !v.matchingOverflow || v.matchingStates != quarkusHTTPMatchingStateLimit || len(v.countDimensions) != quarkusHTTPMatchingStateLimit {
+ if !v.matchingOverflow || v.matchingStates != micrometerHTTPMatchingStateLimit || len(v.countDimensions) != micrometerHTTPMatchingStateLimit {
t.Fatalf("matching state = overflow:%v states:%d groups:%d", v.matchingOverflow, v.matchingStates, len(v.countDimensions))
}
}
@@ -774,3 +774,73 @@ func hasQuarkusCollectorEvent(events []CollectorEvent, eventType, metricKey stri
}
return false
}
+
+func TestQuarkusExtractionPreservesStatusSyntax(t *testing.T) {
+ // Preserve Quarkus's existing Atoi boundary when another adapter introduces
+ // stricter status syntax. Neither uri nor exception is required here.
+ for _, status := range []string{"+404", "0404"} {
+ t.Run(status, func(t *testing.T) {
+ labels := `{method="GET",outcome="CLIENT_ERROR",status="` + status + `"}`
+ result := collectQuarkusBody(t, "process_cpu_usage 0.1\nhttp_server_requests_seconds_count"+labels+" 2\nhttp_server_requests_seconds_sum"+labels+" 0.5\n")
+ assertSample(t, result, "http_404_total", MetricKindCounter, 2, "requests")
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 0.5, "seconds")
+ if len(result.Events) != 0 {
+ t.Fatalf("events = %#v", result.Events)
+ }
+ })
+ }
+}
+
+func TestQuarkusExtractionRejectsInvalidHTTPValues(t *testing.T) {
+ for _, value := range []string{"-1", "NaN", "+Inf", "-Inf"} {
+ t.Run(value, func(t *testing.T) {
+ result := collectQuarkusBody(t, `process_cpu_usage 0.1
+http_server_requests_seconds_count{method="GET",outcome="SUCCESS",status="200"} 3
+http_server_requests_seconds_sum{method="GET",outcome="SUCCESS",status="200"} 2
+http_server_requests_seconds_count{method="GET",outcome="SUCCESS",status="200",uri="/bad"} `+value+`
+http_server_requests_seconds_sum{method="GET",outcome="SUCCESS",status="200",uri="/bad"} `+value+"\n")
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 3, "requests")
+ assertSample(t, result, "http_request_time_total_seconds", MetricKindCounter, 2, "seconds")
+ if hasSample(result, "http_4xx_total") || len(result.Events) != 2 {
+ t.Fatalf("result = %#v", result)
+ }
+ for _, event := range result.Events {
+ if event.Type != "metric_dimension_invalid" {
+ t.Fatalf("event = %#v", event)
+ }
+ }
+ })
+ }
+}
+
+func TestQuarkusExtractionDurationDuplicatePreservesCounts(t *testing.T) {
+ result := collectQuarkusBody(t, `process_cpu_usage 0.1
+http_server_requests_seconds_count{method="GET",outcome="SUCCESS",status="200"} 3
+http_server_requests_seconds_sum{method="GET",outcome="SUCCESS",status="200"} 2
+http_server_requests_seconds_sum{status="200",outcome="SUCCESS",method="GET"} 2
+`)
+ assertSample(t, result, "http_requests_total", MetricKindCounter, 3, "requests")
+ assertSample(t, result, "http_4xx_total", MetricKindCounter, 0, "requests")
+ if hasSample(result, "http_request_time_total_seconds") || len(result.Events) != 1 || result.Events[0].Type != "metric_series_duplicate" {
+ t.Fatalf("result = %#v", result)
+ }
+}
+
+func TestQuarkusExtractionCombinedMatchingLimit(t *testing.T) {
+ v := micrometerHTTPValues{}
+ for i := 0; i < micrometerHTTPMatchingStateLimit/2; i++ {
+ sample := prometheus.Sample{Value: 1, Labels: []prometheus.Label{{Name: "method", Value: "GET"}, {Name: "outcome", Value: "SUCCESS"}, {Name: "status", Value: "200"}, {Name: "uri", Value: strconv.Itoa(i)}}}
+ v.acceptCount(sample, validateQuarkusHTTPLabels)
+ v.acceptDuration(sample, validateQuarkusHTTPLabels)
+ }
+ if v.matchingOverflow || !v.durationMatchesCounts() {
+ t.Fatal("exact combined limit must remain usable")
+ }
+ v.acceptDuration(prometheus.Sample{Value: 1, Labels: []prometheus.Label{{Name: "method", Value: "POST"}, {Name: "outcome", Value: "SUCCESS"}, {Name: "status", Value: "200"}}}, validateQuarkusHTTPLabels)
+ result := &CollectionResult{}
+ v.addTo(result)
+ addQuarkusHTTPWarnings(result, &v)
+ if !v.matchingOverflow || v.matchingStates != micrometerHTTPMatchingStateLimit || len(v.countDimensions)+len(v.durationDimensions) != micrometerHTTPMatchingStateLimit || len(result.Samples) != 0 || len(result.Events) != 1 || result.Events[0].Type != "metric_aggregation_limit" {
+ t.Fatalf("result = %#v, states = %d", result, v.matchingStates)
+ }
+}
From b8eee0e3b22df1e8c4dead322104b3ed668f15c7 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Wed, 30 Sep 2026 17:05:54 -0700
Subject: [PATCH 02/13] Add Micronaut target configuration and independent
health (#56)
---
internal/app/bootstrap.go | 27 ++
internal/app/bootstrap_test.go | 117 ++++++++
internal/collector/micronaut.go | 103 +++++--
internal/collector/micronaut_health.go | 177 +++++++++++++
internal/collector/micronaut_health_test.go | 280 ++++++++++++++++++++
internal/collector/micronaut_test.go | 4 +-
internal/config/config.go | 60 ++++-
internal/config/config_test.go | 13 +-
internal/config/micronaut_config_test.go | 167 ++++++++++++
internal/dashboard/static/dashboard.js | 2 +
internal/dashboard/static/dashboard.test.js | 7 +
internal/monitor/monitor.go | 2 +
internal/monitor/monitor_test.go | 103 +++++++
13 files changed, 1032 insertions(+), 30 deletions(-)
create mode 100644 internal/collector/micronaut_health.go
create mode 100644 internal/collector/micronaut_health_test.go
create mode 100644 internal/config/micronaut_config_test.go
diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go
index e7a4354..c8d688d 100644
--- a/internal/app/bootstrap.go
+++ b/internal/app/bootstrap.go
@@ -46,6 +46,8 @@ func newCollector(target config.TargetConfig, timeout time.Duration) (monitor.Co
return newSpringCollector(target, timeout)
case config.TargetTypeStatliteMetrics:
return newStatliteMetricsCollector(target, timeout)
+ case config.TargetTypeMicronaut:
+ return newMicronautCollector(target, timeout)
case config.TargetTypeQuarkus:
return newQuarkusCollector(target, timeout)
default:
@@ -150,3 +152,28 @@ func springEndpoint(base, endpoint string) (string, error) {
u.Fragment = ""
return u.String(), nil
}
+
+func newMicronautCollector(target config.TargetConfig, timeout time.Duration) (monitor.Collector, error) {
+ client, err := prometheus.NewClient(timeout, prometheus.DefaultLimits, prometheusAuthConfig(target.Auth))
+ if err != nil {
+ return nil, fmt.Errorf("micronaut metrics client: %w", err)
+ }
+ healthURL := target.HealthURL
+ if healthURL == "" {
+ healthURL, err = config.DefaultMicronautHealthURL(target.URL)
+ if err != nil {
+ return nil, fmt.Errorf("micronaut health URL: %w", err)
+ }
+ }
+ var healthClient *collector.MicronautHealthClient
+ if healthURL != "" {
+ healthClient, err = collector.NewMicronautHealthClient(healthURL, timeout, collectorAuthConfig(target.Auth))
+ if err != nil {
+ return nil, fmt.Errorf("micronaut health client: %w", err)
+ }
+ if target.HealthURL == "" {
+ healthClient.TreatNotFoundAsOptional()
+ }
+ }
+ return collector.NewMicronautCollector(target.Name, target.URL, client, healthClient), nil
+}
diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go
index 26fec7a..7628e8a 100644
--- a/internal/app/bootstrap_test.go
+++ b/internal/app/bootstrap_test.go
@@ -474,3 +474,120 @@ targets:
func typeName(value any) string {
return reflect.TypeOf(value).String()
}
+
+func TestNewCollectorAllowsCustomMicronautMetricsEndpointWithoutHealth(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if u, p, ok := r.BasicAuth(); !ok || u != "u" || p != "p" {
+ t.Error("missing metrics auth")
+ }
+ if r.RequestURI != "/manage%2Fprom/?scope=app" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = w.Write([]byte("process_cpu_usage 0.25\n"))
+ }))
+ defer server.Close()
+
+ targetCollector, err := newCollector(config.TargetConfig{
+ Name: "orders",
+ Type: config.TargetTypeMicronaut,
+ Auth: &config.AuthConfig{Type: "basic", Username: "u", Password: "p"},
+ URL: server.URL + "/manage%2Fprom/?scope=app",
+ }, time.Second)
+ if err != nil {
+ t.Fatalf("newCollector() error = %v", err)
+ }
+ result, err := targetCollector.Collect(context.Background())
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ if len(result.Samples) != 1 || result.Samples[0].Key != "process_cpu_usage" || result.HealthStatus != "" || result.DBHealthStatus != "" || len(result.Events) != 0 {
+ t.Fatalf("result = %#v, want metrics-only custom endpoint without synthesized health", result)
+ }
+}
+
+func TestNewCollectorDerivesMicronautHealthEndpointAndSharesAuth(t *testing.T) {
+ var metricsRequests, healthRequests int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, password, ok := r.BasicAuth()
+ if !ok || user != "user" || password != "secret" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ switch r.URL.EscapedPath() {
+ case "/svc%2Fwest/prometheus/":
+ metricsRequests++
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = w.Write([]byte("process_cpu_usage 0.25\n"))
+ case "/svc%2Fwest/health":
+ healthRequests++
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"status":"UP","details":{"jdbc":{"status":"UP"}}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ targetCollector, err := newCollector(config.TargetConfig{
+ Name: "orders",
+ Type: config.TargetTypeMicronaut,
+ URL: server.URL + "/svc%2Fwest/prometheus/",
+ Auth: &config.AuthConfig{Type: "basic", Username: "user", Password: "secret"},
+ }, time.Second)
+ if err != nil {
+ t.Fatalf("newCollector() error = %v", err)
+ }
+ result, err := targetCollector.Collect(context.Background())
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ if metricsRequests != 1 || healthRequests != 1 {
+ t.Fatalf("requests = metrics:%d health:%d, want 1/1", metricsRequests, healthRequests)
+ }
+ if result.HealthStatus != "UP" || result.DBHealthStatus != "UP" {
+ t.Fatalf("health = %q/%q, want UP/UP", result.HealthStatus, result.DBHealthStatus)
+ }
+}
+
+func TestNewCollectorUsesExplicitMicronautHealthURL(t *testing.T) {
+ var healthRequests int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, password, ok := r.BasicAuth()
+ if !ok || user != "user" || password != "secret" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ switch r.URL.EscapedPath() {
+ case "/manage/prom":
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = w.Write([]byte("process_cpu_usage 0.25\n"))
+ case "/manage/health":
+ healthRequests++
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"status":"UP","details":{"jdbc":{"status":"UP"}}}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ targetCollector, err := newCollector(config.TargetConfig{
+ Name: "orders",
+ Type: config.TargetTypeMicronaut,
+ URL: server.URL + "/manage/prom",
+ HealthURL: server.URL + "/manage/health",
+ Auth: &config.AuthConfig{Type: "basic", Username: "user", Password: "secret"},
+ }, time.Second)
+ if err != nil {
+ t.Fatalf("newCollector() error = %v", err)
+ }
+ result, err := targetCollector.Collect(context.Background())
+ if err != nil {
+ t.Fatalf("Collect() error = %v", err)
+ }
+ if healthRequests != 1 || result.HealthStatus != "UP" || result.DBHealthStatus != "UP" {
+ t.Fatalf("health requests/status = %d %q/%q, want 1 UP/UP", healthRequests, result.HealthStatus, result.DBHealthStatus)
+ }
+}
diff --git a/internal/collector/micronaut.go b/internal/collector/micronaut.go
index e842b47..5cf343a 100644
--- a/internal/collector/micronaut.go
+++ b/internal/collector/micronaut.go
@@ -7,6 +7,7 @@ import (
"fmt"
"slices"
"strings"
+ "sync"
"time"
"github.com/pvrlabs/statlite/internal/prometheus"
@@ -14,11 +15,23 @@ import (
// MicronautCollector performs one bounded scrape of the exact metrics endpoint.
type MicronautCollector struct {
- targetName string
- endpoint string
- client *prometheus.Client
+ targetName string
+ endpoint string
+ client *prometheus.Client
+ healthClient *MicronautHealthClient
+ healthStateMu sync.Mutex
+ healthCapability micronautHealthCapability
+ healthProcessStartTime *time.Time
}
+type micronautHealthCapability uint8
+
+const (
+ micronautHealthUnknown micronautHealthCapability = iota
+ micronautHealthAvailable
+ micronautHealthAbsent
+)
+
var ErrMicronautIncompatible = errors.New("Micronaut metrics endpoint does not expose a finite recognized runtime family")
// MicronautInspection describes supported concepts, not proven framework origin.
@@ -34,12 +47,12 @@ type micronautEvaluation struct {
processStartTime *time.Time
}
-func NewMicronautCollector(targetName, endpoint string, client *prometheus.Client) *MicronautCollector {
- return &MicronautCollector{targetName: targetName, endpoint: endpoint, client: client}
+func NewMicronautCollector(targetName, endpoint string, client *prometheus.Client, healthClient *MicronautHealthClient) *MicronautCollector {
+ return &MicronautCollector{targetName: targetName, endpoint: endpoint, client: client, healthClient: healthClient}
}
func InspectMicronaut(ctx context.Context, endpoint string, client *prometheus.Client) (*MicronautInspection, error) {
- evaluation, err := NewMicronautCollector("", endpoint, client).evaluate(ctx)
+ evaluation, err := NewMicronautCollector("", endpoint, client, nil).evaluate(ctx)
if err != nil {
return nil, err
}
@@ -60,26 +73,82 @@ func InspectMicronaut(ctx context.Context, endpoint string, client *prometheus.C
}
func (c *MicronautCollector) Collect(ctx context.Context) (*CollectionResult, error) {
- result := &CollectionResult{TargetName: c.targetName, PollStartedAt: time.Now().UTC()}
+ started := time.Now().UTC()
+ result := &CollectionResult{TargetName: c.targetName, PollStartedAt: started}
defer func() { result.PollFinishedAt = time.Now().UTC() }()
if c.client == nil || c.endpoint == "" {
err := errors.New("Micronaut metrics client is not configured")
result.addEvent(EventSeverityError, "collector_not_configured", "", err.Error())
return result, err
}
- evaluation, err := c.evaluate(ctx)
- if err != nil {
- if errors.Is(err, ErrMicronautIncompatible) {
- result.addEvent(EventSeverityError, "metrics_source_incompatible", "", err.Error())
+ evaluation, metricsErr := c.evaluate(ctx)
+ if metricsErr != nil {
+ if errors.Is(metricsErr, ErrMicronautIncompatible) {
+ result.addEvent(EventSeverityError, "metrics_source_incompatible", "", metricsErr.Error())
} else {
- result.addEvent(EventSeverityError, "metrics_fetch_failed", "", err.Error())
+ result.addEvent(EventSeverityError, "metrics_fetch_failed", "", metricsErr.Error())
}
- return result, err
+ } else {
+ result.Samples = evaluation.samples
+ result.Events = append(result.Events, evaluation.events...)
+ result.ProcessStartTime = evaluation.processStartTime
}
- result.Samples = evaluation.samples
- result.Events = evaluation.events
- result.ProcessStartTime = evaluation.processStartTime
- return result, nil
+
+ if c.healthClient != nil {
+ if c.shouldProbeHealth(result.ProcessStartTime) {
+ health, err := c.healthClient.Fetch(ctx)
+ if err != nil {
+ if c.healthClient.notFoundOptional && errors.Is(err, ErrMicronautHealthNotFound) && c.health404IsOptional() && metricsErr == nil {
+ c.markHealthAbsent(result.ProcessStartTime)
+ } else {
+ result.addEvent(EventSeverityWarning, "health_fetch_failed", "", err.Error())
+ }
+ } else {
+ result.HealthStatus = health.Status
+ result.DBHealthStatus = health.DatabaseStatus
+ if health.Warning != "" {
+ result.addEvent(EventSeverityWarning, "health_partial", "", health.Warning)
+ }
+ c.markHealthAvailable(result.ProcessStartTime)
+ }
+ }
+ }
+ return result, metricsErr
+}
+
+func (c *MicronautCollector) shouldProbeHealth(processStartTime *time.Time) bool {
+ c.healthStateMu.Lock()
+ defer c.healthStateMu.Unlock()
+ if processStartTime != nil {
+ if c.healthProcessStartTime != nil && !processStartTime.Equal(*c.healthProcessStartTime) {
+ c.healthCapability = micronautHealthUnknown
+ }
+ c.healthProcessStartTime = cloneTime(processStartTime)
+ }
+ if c.healthCapability == micronautHealthAbsent {
+ return false
+ }
+ return true
+}
+
+func (c *MicronautCollector) markHealthAbsent(processStartTime *time.Time) {
+ c.healthStateMu.Lock()
+ defer c.healthStateMu.Unlock()
+ c.healthCapability = micronautHealthAbsent
+ c.healthProcessStartTime = cloneTime(processStartTime)
+}
+
+func (c *MicronautCollector) health404IsOptional() bool {
+ c.healthStateMu.Lock()
+ defer c.healthStateMu.Unlock()
+ return c.healthCapability == micronautHealthUnknown
+}
+
+func (c *MicronautCollector) markHealthAvailable(processStartTime *time.Time) {
+ c.healthStateMu.Lock()
+ defer c.healthStateMu.Unlock()
+ c.healthCapability = micronautHealthAvailable
+ c.healthProcessStartTime = cloneTime(processStartTime)
}
func (c *MicronautCollector) evaluate(ctx context.Context) (*micronautEvaluation, error) {
diff --git a/internal/collector/micronaut_health.go b/internal/collector/micronaut_health.go
new file mode 100644
index 0000000..dccba4b
--- /dev/null
+++ b/internal/collector/micronaut_health.go
@@ -0,0 +1,177 @@
+package collector
+
+// This file fetches and normalizes Micronaut management health responses.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/pvrlabs/statlite/internal/urlshape"
+)
+
+const micronautHealthMaxResponseBytes = 1 << 20
+const micronautHealthErrorExcerptBytes = 1024
+
+var ErrMicronautHealthNotFound = errors.New("Micronaut health endpoint not found")
+
+type MicronautHealthClient struct {
+ url string
+ httpClient *http.Client
+ auth *BasicAuth
+ notFoundOptional bool
+}
+
+// TreatNotFoundAsOptional marks the conventional health endpoint as an
+// optional capability, such as when management health is disabled.
+func (c *MicronautHealthClient) TreatNotFoundAsOptional() {
+ c.notFoundOptional = true
+}
+
+// MicronautHealthResponse retains only fixed statuses and a bounded diagnostic.
+type MicronautHealthResponse struct {
+ Status string
+ DatabaseStatus string
+ Warning string
+}
+
+func NewMicronautHealthClient(rawURL string, timeout time.Duration, auth *BasicAuth) (*MicronautHealthClient, error) {
+ if timeout <= 0 {
+ return nil, fmt.Errorf("Micronaut health timeout must be positive")
+ }
+ if strings.TrimSpace(rawURL) != rawURL {
+ return nil, errors.New("Micronaut health URL must not contain surrounding whitespace")
+ }
+ parsed, err := urlshape.ParseHTTP(rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("Micronaut health URL: %w", err)
+ }
+ if parsed.User != nil {
+ return nil, errors.New("Micronaut health URL must not include user information")
+ }
+ return &MicronautHealthClient{
+ url: parsed.String(),
+ httpClient: &http.Client{Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) > 3 {
+ return errors.New("Micronaut health redirect limit exceeded")
+ }
+ if len(via) > 0 && !micronautHealthSameOrigin(req.URL, via[0].URL) {
+ return errors.New("Micronaut health redirects may not change origin")
+ }
+ if auth != nil {
+ req.SetBasicAuth(auth.Username, auth.Password)
+ }
+ return nil
+ }},
+ auth: auth,
+ }, nil
+}
+
+func (c *MicronautHealthClient) Fetch(ctx context.Context) (*MicronautHealthResponse, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating Micronaut health request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ if c.auth != nil {
+ req.SetBasicAuth(c.auth.Username, c.auth.Password)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetching Micronaut health: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, micronautHealthMaxResponseBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("reading Micronaut health response: %w", err)
+ }
+ if len(body) > micronautHealthMaxResponseBytes {
+ return nil, fmt.Errorf("Micronaut health response exceeds %d byte limit", micronautHealthMaxResponseBytes)
+ }
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusServiceUnavailable {
+ excerpt := boundedMicronautHealthErrorExcerpt(body)
+ if resp.StatusCode == http.StatusNotFound {
+ return nil, fmt.Errorf("%w: HTTP 404: %s", ErrMicronautHealthNotFound, excerpt)
+ }
+ return nil, fmt.Errorf("Micronaut health returned HTTP %d: %s", resp.StatusCode, excerpt)
+ }
+ return ParseMicronautHealthResponse(body)
+}
+
+func boundedMicronautHealthErrorExcerpt(body []byte) string {
+ excerpt := strings.TrimSpace(string(body))
+ if len(excerpt) > micronautHealthErrorExcerptBytes {
+ excerpt = excerpt[:micronautHealthErrorExcerptBytes]
+ }
+ return excerpt
+}
+
+func micronautHealthSameOrigin(a, b *url.URL) bool {
+ port := func(u *url.URL) string {
+ if p := u.Port(); p != "" {
+ return p
+ }
+ if strings.EqualFold(u.Scheme, "https") {
+ return "443"
+ }
+ return "80"
+ }
+ return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Hostname(), b.Hostname()) && port(a) == port(b)
+}
+
+// ParseMicronautHealthResponse validates application status first. Optional JDBC
+// shape errors preserve that authoritative status; datasource children are ignored.
+func ParseMicronautHealthResponse(body []byte) (*MicronautHealthResponse, error) {
+ var root map[string]json.RawMessage
+ if err := json.Unmarshal(body, &root); err != nil || root == nil {
+ return nil, errors.New("Micronaut health response must be a single JSON object")
+ }
+ status := micronautHealthStatus(root["status"])
+ if status == "" {
+ return nil, errors.New("Micronaut health response has invalid status")
+ }
+ health := &MicronautHealthResponse{Status: status}
+ optionalObject := func(raw json.RawMessage) (map[string]json.RawMessage, bool) {
+ if len(raw) == 0 {
+ return nil, true
+ }
+ var object map[string]json.RawMessage
+ err := json.Unmarshal(raw, &object)
+ return object, err == nil
+ }
+ details, ok := optionalObject(root["details"])
+ if !ok {
+ health.Warning = "Micronaut health details must be an object"
+ return health, nil
+ }
+ jdbc, ok := optionalObject(details["jdbc"])
+ if !ok {
+ health.Warning = "Micronaut JDBC health must be an object"
+ return health, nil
+ }
+ if jdbc != nil {
+ health.DatabaseStatus = micronautHealthStatus(jdbc["status"])
+ if health.DatabaseStatus == "" {
+ health.Warning = "Micronaut JDBC health has invalid status"
+ }
+ }
+ return health, nil
+}
+
+func micronautHealthStatus(raw json.RawMessage) string {
+ var status string
+ if json.Unmarshal(raw, &status) != nil {
+ return ""
+ }
+ status = strings.ToUpper(strings.TrimSpace(status))
+ if status != "UP" && status != "DOWN" {
+ return ""
+ }
+ return status
+}
diff --git a/internal/collector/micronaut_health_test.go b/internal/collector/micronaut_health_test.go
new file mode 100644
index 0000000..8e96673
--- /dev/null
+++ b/internal/collector/micronaut_health_test.go
@@ -0,0 +1,280 @@
+package collector
+
+import (
+ "compress/gzip"
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pvrlabs/statlite/internal/prometheus"
+)
+
+func TestParseMicronautHealthResponse(t *testing.T) {
+ tests := []struct {
+ name, body, app, db string
+ partial, invalid bool
+ }{
+ {"up", `{"status":" up "}`, "UP", "", false, false},
+ {"down", `{"status":"DOWN","details":{"jdbc":{"status":"down"}}}`, "DOWN", "DOWN", false, false},
+ {"hidden", `{"status":"UP","details":null}`, "UP", "", false, false},
+ {"absent jdbc", `{"status":"UP","details":{"diskSpace":17}}`, "UP", "", false, false},
+ {"null jdbc", `{"status":"UP","details":{"jdbc":null}}`, "UP", "", false, false},
+ {"independent aggregate", `{"status":"DOWN","details":{"jdbc":{"status":"UP","details":{"one":{"status":"DOWN"}}}}}`, "DOWN", "UP", false, false},
+ {"ignored children", `{"status":"UP","details":{"jdbc":{"status":"DOWN","details":[17,null,"anything"]}}}`, "UP", "DOWN", false, false},
+ {"invalid details", `{"status":"UP","details":[]}`, "UP", "", true, false},
+ {"invalid jdbc", `{"status":"UP","details":{"jdbc":"UP"}}`, "UP", "", true, false},
+ {"empty jdbc", `{"status":"UP","details":{"jdbc":{}}}`, "UP", "", true, false},
+ {"unknown jdbc", `{"status":"UP","details":{"jdbc":{"status":"UNKNOWN"}}}`, "UP", "", true, false},
+ {"numeric jdbc", `{"status":"UP","details":{"jdbc":{"status":1}}}`, "UP", "", true, false},
+ {"unknown app", `{"status":"UNKNOWN","details":{"jdbc":{"status":"UP"}}}`, "", "", false, true},
+ {"custom app", `{"status":"DEGRADED"}`, "", "", false, true},
+ {"missing app", `{"details":{"jdbc":{"status":"UP"}}}`, "", "", false, true},
+ {"null app", `{"status":null}`, "", "", false, true},
+ {"numeric app", `{"status":1}`, "", "", false, true},
+ {"empty app", `{"status":""}`, "", "", false, true},
+ {"array root", `[]`, "", "", false, true},
+ {"null root", `null`, "", "", false, true},
+ {"trailing JSON", `{"status":"UP"}{}`, "", "", false, true},
+ {"malformed", `{"status":"UP"`, "", "", false, true},
+ {"invalid ignored detail JSON", `{"status":"UP","details":{"other":invalid}}`, "", "", false, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ h, err := ParseMicronautHealthResponse([]byte(tt.body))
+ if tt.invalid {
+ if err == nil || h != nil {
+ t.Fatalf("got %#v, %v", h, err)
+ }
+ return
+ }
+ if err != nil || h.Status != tt.app || h.DatabaseStatus != tt.db || (h.Warning != "") != tt.partial {
+ t.Fatalf("got %#v, %v", h, err)
+ }
+ })
+ }
+}
+
+func TestMicronautHealthTransport(t *testing.T) {
+ for _, code := range []int{200, 503, 401, 403, 404, 410, 500} {
+ for _, status := range []string{"UP", "DOWN"} {
+ t.Run(fmt.Sprintf("%d/%s", code, status), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ u, p, ok := r.BasicAuth()
+ if !ok || u != "user" || p != "secret" {
+ t.Error("missing auth")
+ }
+ if r.RequestURI != "/health/?probe=1" {
+ t.Errorf("URL changed: %s", r.RequestURI)
+ }
+ w.WriteHeader(code)
+ fmt.Fprintf(w, `{"status":%q}`, status)
+ }))
+ defer server.Close()
+ c, err := NewMicronautHealthClient(server.URL+"/health/?probe=1", time.Second, &BasicAuth{Username: "user", Password: "secret"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ h, err := c.Fetch(context.Background())
+ if code == 200 || code == 503 {
+ if err != nil || h.Status != status {
+ t.Fatalf("%#v %v", h, err)
+ }
+ } else if err == nil {
+ t.Fatal("expected HTTP error")
+ }
+ })
+ }
+ }
+ for _, compressed := range []bool{false, true} {
+ t.Run(fmt.Sprintf("body bound gzip=%v", compressed), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := `{"status":"UP","ignored":"` + strings.Repeat("x", micronautHealthMaxResponseBytes) + `"}`
+ if compressed {
+ w.Header().Set("Content-Encoding", "gzip")
+ g := gzip.NewWriter(w)
+ defer g.Close()
+ fmt.Fprint(g, body)
+ } else {
+ fmt.Fprint(w, body)
+ }
+ }))
+ defer server.Close()
+ c, _ := NewMicronautHealthClient(server.URL, time.Second, nil)
+ if _, err := c.Fetch(context.Background()); err == nil || !strings.Contains(err.Error(), "exceeds") {
+ t.Fatalf("expected body bound, got %v", err)
+ }
+ })
+ }
+ t.Run("bounded HTTP error", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(401)
+ fmt.Fprint(w, strings.Repeat("x", 3000))
+ }))
+ defer server.Close()
+ c, _ := NewMicronautHealthClient(server.URL, time.Second, nil)
+ _, err := c.Fetch(context.Background())
+ if err == nil || len(err.Error()) > 1100 {
+ t.Fatalf("unbounded error: %v", err)
+ }
+ })
+}
+
+func TestMicronautHealthRedirectsAndDeadline(t *testing.T) {
+ for _, redirects := range []int{3, 4} {
+ t.Run(fmt.Sprint(redirects), func(t *testing.T) {
+ requests := 0
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if u, p, ok := r.BasicAuth(); !ok || u != "u" || p != "p" {
+ t.Error("redirect lost auth")
+ }
+ if requests <= redirects {
+ http.Redirect(w, r, fmt.Sprintf("/%d", requests), 302)
+ return
+ }
+ fmt.Fprint(w, `{"status":"UP"}`)
+ }))
+ defer s.Close()
+ c, _ := NewMicronautHealthClient(s.URL, time.Second, &BasicAuth{Username: "u", Password: "p"})
+ _, err := c.Fetch(context.Background())
+ if (err != nil) != (redirects == 4) || requests != 4 {
+ t.Fatalf("requests=%d err=%v", requests, err)
+ }
+ })
+ }
+ t.Run("cross origin", func(t *testing.T) {
+ other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("cross-origin request leaked") }))
+ defer other.Close()
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, other.URL, 302) }))
+ defer s.Close()
+ c, _ := NewMicronautHealthClient(s.URL, time.Second, nil)
+ if _, err := c.Fetch(context.Background()); err == nil {
+ t.Fatal("expected redirect rejection")
+ }
+ })
+ for _, useContext := range []bool{false, true} {
+ t.Run(fmt.Sprintf("deadline context=%v", useContext), func(t *testing.T) {
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
+ defer s.Close()
+ timeout := 20 * time.Millisecond
+ ctx := context.Background()
+ if useContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ timeout = time.Second
+ }
+ c, _ := NewMicronautHealthClient(s.URL, timeout, nil)
+ if _, err := c.Fetch(ctx); err == nil {
+ t.Fatal("expected timeout")
+ }
+ })
+ }
+}
+
+func TestMicronautHealthCapabilityLifecycle(t *testing.T) {
+ type step struct {
+ start int
+ metricsFail bool
+ code int
+ body, app, db, warning string
+ probes int
+ }
+ tests := []struct {
+ name string
+ optional bool
+ steps []step
+ }{
+ {"absent until restart", true, []step{
+ {start: 1770000000, code: 404, probes: 1},
+ {start: 1770000000, code: 200, body: `{"status":"UP"}`, probes: 1},
+ {start: 1770000060, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 2},
+ }},
+ {"known loss and recovery", true, []step{
+ {start: 1770000000, code: 200, body: `{"status":"UP","details":{"jdbc":{"status":"UP"}}}`, app: "UP", db: "UP", probes: 1},
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 2},
+ {start: 1770000000, code: 200, body: `{"status":"DOWN"}`, app: "DOWN", probes: 3},
+ }},
+ {"explicit always probes", false, []step{
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 1},
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 2},
+ }},
+ {"failed metrics cannot cache absence", true, []step{
+ {metricsFail: true, code: 404, warning: "health_fetch_failed", probes: 1},
+ {start: 1770000000, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 2},
+ {metricsFail: true, code: 503, body: `{"status":"DOWN","details":{"jdbc":{"status":"UP"}}}`, app: "DOWN", db: "UP", probes: 3},
+ }},
+ {"retry errors and partial details", true, []step{
+ {start: 1770000000, code: 401, warning: "health_fetch_failed", probes: 1},
+ {start: 1770000000, code: 200, body: `{"status":"UNKNOWN"}`, warning: "health_fetch_failed", probes: 2},
+ {start: 1770000000, code: 200, body: `{"status":"UP","details":[]}`, app: "UP", warning: "health_partial", probes: 3},
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 4},
+ {start: 1770000000, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 5},
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var current step
+ probes, metrics := 0, 0
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/prometheus" {
+ metrics++
+ if current.metricsFail {
+ http.Error(w, "failed", 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprintf(w, "process_start_time_seconds %d\nprocess_cpu_usage 0.2\n", current.start)
+ return
+ }
+ probes++
+ if metrics <= probes-1 {
+ t.Error("health preceded metrics")
+ }
+ w.WriteHeader(current.code)
+ fmt.Fprint(w, current.body)
+ }))
+ defer s.Close()
+ pc, _ := prometheus.NewClient(time.Second, prometheus.DefaultLimits, nil)
+ hc, _ := NewMicronautHealthClient(s.URL+"/health", time.Second, nil)
+ if tt.optional {
+ hc.TreatNotFoundAsOptional()
+ }
+ c := NewMicronautCollector("app", s.URL+"/prometheus", pc, hc)
+ for i, st := range tt.steps {
+ current = st
+ r, err := c.Collect(context.Background())
+ if (err != nil) != st.metricsFail || r.HealthStatus != st.app || r.DBHealthStatus != st.db || probes != st.probes {
+ t.Fatalf("step %d: result=%#v err=%v probes=%d", i, r, err, probes)
+ }
+ warning := ""
+ for _, e := range r.Events {
+ if e.Severity == EventSeverityWarning {
+ warning = e.Type
+ }
+ }
+ if warning != st.warning {
+ t.Fatalf("step %d events=%#v want %q", i, r.Events, st.warning)
+ }
+ if !st.metricsFail && len(r.Samples) != 2 {
+ t.Fatalf("health invalidated metrics: %#v", r)
+ }
+ }
+ })
+ }
+}
+
+func TestMicronautHealthClientRejectsInvalidURLs(t *testing.T) {
+ for _, raw := range []string{" http://example.com/health", "http://example.com/health ", "ftp://example.com/health", "http:///health", "http://user:secret@example.com/health", "http://example.com/health#", "http:health"} {
+ if _, err := NewMicronautHealthClient(raw, time.Second, nil); err == nil {
+ t.Errorf("accepted %q", raw)
+ }
+ }
+ if _, err := NewMicronautHealthClient("http://example.com/health", 0, nil); err == nil {
+ t.Fatal("accepted nonpositive timeout")
+ }
+}
diff --git a/internal/collector/micronaut_test.go b/internal/collector/micronaut_test.go
index d1758cf..7d4bf75 100644
--- a/internal/collector/micronaut_test.go
+++ b/internal/collector/micronaut_test.go
@@ -37,7 +37,7 @@ func newTestMicronautCollector(t *testing.T, handler http.HandlerFunc) *Micronau
if err != nil {
t.Fatal(err)
}
- return NewMicronautCollector("orders", server.URL+"/prometheus", client)
+ return NewMicronautCollector("orders", server.URL+"/prometheus", client, nil)
}
func micronautBodyCollector(t *testing.T, body string) *MicronautCollector {
@@ -413,7 +413,7 @@ func TestMicronautCollectorNotConfigured(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- for _, c := range []*MicronautCollector{NewMicronautCollector("orders", "http://localhost/prometheus", nil), NewMicronautCollector("orders", "", client)} {
+ for _, c := range []*MicronautCollector{NewMicronautCollector("orders", "http://localhost/prometheus", nil, nil), NewMicronautCollector("orders", "", client, nil)} {
result, err := c.Collect(context.Background())
if err == nil || len(result.Events) != 1 || result.Events[0].Type != "collector_not_configured" || result.PollFinishedAt.IsZero() {
t.Fatalf("result = %#v, error = %v", result, err)
diff --git a/internal/config/config.go b/internal/config/config.go
index c3b97c0..9b3c980 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -18,6 +18,7 @@ const (
// When adding a target type, also update targetTypeHelp in the dashboard.
TargetTypeSpring = "spring"
TargetTypeQuarkus = "quarkus"
+ TargetTypeMicronaut = "micronaut"
TargetTypeStatliteMetrics = "statlite-metrics"
SpringMetricsSourceAuto = "auto"
@@ -266,16 +267,20 @@ func (c *Config) validateTargets() error {
if err := validateQuarkusTarget(target); err != nil {
return err
}
+ case TargetTypeMicronaut:
+ if err := validateMicronautTarget(target); err != nil {
+ return err
+ }
case TargetTypeStatliteMetrics:
if err := validateStatliteMetricsTarget(target); err != nil {
return err
}
default:
- return targetError(target, "type", fmt.Sprintf("unsupported value %q (supported: spring, quarkus, statlite-metrics)", targetType))
+ return targetError(target, "type", fmt.Sprintf("unsupported value %q (supported: spring, quarkus, micronaut, statlite-metrics)", targetType))
}
if target.Auth != nil {
- if targetType != TargetTypeSpring && targetType != TargetTypeQuarkus {
- return targetError(target, "auth", "is supported only for type spring and quarkus")
+ if targetType != TargetTypeSpring && targetType != TargetTypeQuarkus && targetType != TargetTypeMicronaut {
+ return targetError(target, "auth", "is supported only for type spring, quarkus, and micronaut")
}
if target.Auth.Type != "basic" {
return targetError(target, "auth.type", fmt.Sprintf("unsupported value %q (only basic is supported)", target.Auth.Type))
@@ -293,8 +298,8 @@ func (c *Config) validateTargets() error {
if (target.CollectHostMetrics || target.collectHostSet) && targetType != TargetTypeSpring {
return targetError(target, "collect_host_metrics", "is supported only for type spring")
}
- if (target.HealthURL != "" || target.healthURLSet) && targetType != TargetTypeQuarkus {
- return targetError(target, "health_url", "is supported only for type quarkus")
+ if (target.HealthURL != "" || target.healthURLSet) && targetType != TargetTypeQuarkus && targetType != TargetTypeMicronaut {
+ return targetError(target, "health_url", "is supported only for type quarkus and micronaut")
}
}
return nil
@@ -351,6 +356,33 @@ func validateQuarkusTarget(target *TargetConfig) error {
return nil
}
+func validateMicronautTarget(target *TargetConfig) error {
+ if target.URL == "" {
+ return targetError(target, "url", "is required for type micronaut")
+ }
+ if target.ActuatorBaseURL != "" || target.actuatorURLSet {
+ return targetError(target, "actuator_base_url", "is supported only for type spring")
+ }
+ if target.metricsSourceSet {
+ return targetError(target, "metrics_source", "is supported only for type spring")
+ }
+ if target.collectHostSet {
+ return targetError(target, "collect_host_metrics", "is supported only for type spring")
+ }
+ if err := validateTargetURL(target.URL, true, false); err != nil {
+ return targetURLValidationError(target, "url", err)
+ }
+ if target.HealthURL != "" {
+ if err := validateTargetURL(target.HealthURL, true, false); err != nil {
+ return targetURLValidationError(target, "health_url", err)
+ }
+ }
+ if target.MetricsSource != "" {
+ return targetError(target, "metrics_source", "is supported only for type spring")
+ }
+ return nil
+}
+
func validateStatliteMetricsTarget(target *TargetConfig) error {
if target.URL == "" {
return targetError(target, "url", fmt.Sprintf("is required for type %s", target.Type))
@@ -459,3 +491,21 @@ func sanitizeEndpoint(endpoint string) string {
parsed.User = nil
return parsed.String()
}
+
+// DefaultMicronautHealthURL preserves the escaped context path and derives
+// health only from a literal conventional metrics suffix.
+func DefaultMicronautHealthURL(metricsURL string) (string, error) {
+ if err := validateTargetURL(metricsURL, true, false); err != nil {
+ return "", err
+ }
+ u, _ := url.Parse(metricsURL)
+ escaped := strings.TrimSuffix(u.EscapedPath(), "/")
+ if !strings.HasSuffix(escaped, "/prometheus") {
+ return "", nil
+ }
+ u.RawPath = strings.TrimSuffix(escaped, "/prometheus") + "/health"
+ u.Path, _ = url.PathUnescape(u.RawPath)
+ u.RawQuery = ""
+ u.ForceQuery = false
+ return u.String(), nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5846726..1c288de 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -167,7 +167,7 @@ func TestValidateRejectsProgrammaticUnsupportedTargetFields(t *testing.T) {
targetType: TargetTypeSpring,
url: "http://example.com/actuator",
healthURL: "http://example.com/health",
- want: "health_url: is supported only for type quarkus",
+ want: "health_url: is supported only for type quarkus and micronaut",
},
}
for _, tt := range tests {
@@ -184,15 +184,16 @@ func TestValidateRejectsProgrammaticUnsupportedTargetFields(t *testing.T) {
HealthURL: tt.healthURL,
}},
}
- if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), tt.want) {
- t.Fatalf("Validate() error = %v, want %q", err, tt.want)
+ want := `invalid target "app": ` + tt.want
+ if err := Validate(cfg); err == nil || err.Error() != want {
+ t.Fatalf("Validate() error = %v, want %q", err, want)
}
})
}
}
func TestValidateTargetURLsHaveSupportedStructure(t *testing.T) {
- types := []string{TargetTypeSpring, TargetTypeQuarkus, TargetTypeStatliteMetrics}
+ types := []string{TargetTypeSpring, TargetTypeQuarkus, TargetTypeMicronaut, TargetTypeStatliteMetrics}
invalidURLs := []struct{ value, reason string }{
{"ftp://example.com/metrics", `unsupported URL scheme "ftp"`},
{"http:///metrics", "must include a host"},
@@ -239,7 +240,7 @@ func TestValidateStructurallyValidUnreachableURL(t *testing.T) {
}
func TestValidateAcceptsPercentEncodedHashInTargetURLs(t *testing.T) {
- for _, targetType := range []string{TargetTypeSpring, TargetTypeQuarkus, TargetTypeStatliteMetrics} {
+ for _, targetType := range []string{TargetTypeSpring, TargetTypeQuarkus, TargetTypeMicronaut, TargetTypeStatliteMetrics} {
t.Run(targetType, func(t *testing.T) {
cfg := validConfig(targetType, "http://example.com/metrics%23suffix")
if err := Validate(cfg); err != nil {
@@ -897,7 +898,7 @@ targets:
if err == nil {
t.Fatal("Load() error = nil, want error")
}
- if !strings.Contains(err.Error(), "auth: is supported only for type spring and quarkus") {
+ if !strings.Contains(err.Error(), "auth: is supported only for type spring, quarkus, and micronaut") {
t.Fatalf("Load() error = %q, want spring-only auth error", err)
}
})
diff --git a/internal/config/micronaut_config_test.go b/internal/config/micronaut_config_test.go
new file mode 100644
index 0000000..b8a9e9c
--- /dev/null
+++ b/internal/config/micronaut_config_test.go
@@ -0,0 +1,167 @@
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLoadAcceptsMicronautExactMetricsURLAndBasicAuth(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ listen: "127.0.0.1:9090"
+storage:
+ sqlite_path: "./statlite.sqlite"
+polling:
+ interval: "30s"
+targets:
+ - name: orders
+ type: micronaut
+ url: "http://localhost:9000/prometheus/?scope=app"
+ auth:
+ type: basic
+ username: user
+ password: secret
+`)
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ target := cfg.Targets[0]
+ if target.Type != TargetTypeMicronaut || target.URL != "http://localhost:9000/prometheus/?scope=app" {
+ t.Fatalf("target = %#v, want literal Micronaut endpoint", target)
+ }
+ healthURL, err := DefaultMicronautHealthURL(target.URL)
+ if err != nil || healthURL != "http://localhost:9000/health" {
+ t.Fatalf("DefaultMicronautHealthURL() = %q, %v; want conventional Micronaut health endpoint", healthURL, err)
+ }
+ if got := target.DisplayMetadata(); got.Endpoint != target.URL || got.EndpointSource != "url" || got.Type != TargetTypeMicronaut {
+ t.Fatalf("DisplayMetadata() = %#v, want Micronaut URL metadata", got)
+ }
+}
+
+func TestLoadRejectsInvalidMicronautFields(t *testing.T) {
+ tests := []struct{ name, fields, want string }{
+ {"missing url", "", "url: is required for type micronaut"},
+ {"actuator url", "url: http://example.com/prometheus\n actuator_base_url: http://example.com/actuator", "actuator_base_url: is supported only for type spring"},
+ {"host metrics", "url: http://example.com/prometheus\n collect_host_metrics: true", "collect_host_metrics: is supported only for type spring"},
+ {"false host metrics", "url: http://example.com/prometheus\n collect_host_metrics: false", "collect_host_metrics: is supported only for type spring"},
+ {"spring source", "url: http://example.com/prometheus\n metrics_source: prometheus", "metrics_source: is supported only for type spring"},
+ {"empty spring source", "url: http://example.com/prometheus\n metrics_source: \"\"", "metrics_source: is supported only for type spring"},
+ {"empty actuator url", "url: http://example.com/prometheus\n actuator_base_url: \"\"", "actuator_base_url: is supported only for type spring"},
+ {"whitespace", "url: \"http://example.com/prometheus \"", "without surrounding whitespace"},
+ {"fragment", "url: http://example.com/prometheus#section", "must not contain a fragment"},
+ {"userinfo", "url: http://user:secret@example.com/prometheus", "url: must not contain embedded credentials"},
+ {"scheme", "url: ftp://example.com/prometheus", `url: unsupported URL scheme "ftp"`},
+ {"health fragment", "url: http://example.com/prometheus\n health_url: http://example.com/health#section", "health_url: must not contain a fragment"},
+ {"health empty fragment", "url: http://example.com/prometheus\n health_url: http://example.com/health#", "health_url: must not contain a fragment"},
+ {"health userinfo", "url: http://example.com/prometheus\n health_url: http://user:secret@example.com/health", "health_url: must not contain embedded credentials"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ listen: "127.0.0.1:9090"
+storage:
+ sqlite_path: "./statlite.sqlite"
+polling:
+ interval: "30s"
+targets:
+ - name: orders
+ type: micronaut
+ `+tt.fields+`
+`)
+ _, err := Load(path)
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("Load() error = %v, want %q", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestLoadAcceptsExplicitMicronautHealthURL(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ listen: "127.0.0.1:9090"
+storage:
+ sqlite_path: "./statlite.sqlite"
+polling:
+ interval: "30s"
+targets:
+ - name: orders
+ type: micronaut
+ url: "http://example.com/manage/prom"
+ health_url: "https://health.example.com/manage%2Fhealth/?probe=1"
+`)
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ if got := cfg.Targets[0].HealthURL; got != "https://health.example.com/manage%2Fhealth/?probe=1" {
+ t.Fatalf("HealthURL = %q, want explicit override", got)
+ }
+}
+
+func TestLoadAcceptsCustomMicronautMetricsEndpointWithoutDerivedHealth(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ listen: "127.0.0.1:9090"
+storage:
+ sqlite_path: "./statlite.sqlite"
+polling:
+ interval: "30s"
+targets:
+ - name: orders
+ type: micronaut
+ url: "http://example.com/manage/prom"
+`)
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+ got, err := DefaultMicronautHealthURL(cfg.Targets[0].URL)
+ if err != nil || got != "" {
+ t.Fatalf("DefaultMicronautHealthURL() = %q, %v; want unavailable convention", got, err)
+ }
+}
+
+func TestDefaultMicronautHealthURLPreservesContextAndDropsMetricsQuery(t *testing.T) {
+ tests := []struct {
+ metrics string
+ want string
+ }{
+ {"http://localhost:9000/prometheus", "http://localhost:9000/health"},
+ {"http://localhost:9000/prometheus/", "http://localhost:9000/health"},
+ {"https://example.com/service/prometheus?scope=app", "https://example.com/service/health"},
+ {"http://example.com/manage/metrics", ""},
+ {"http://example.com/svc%2Fwest/prometheus/?", "http://example.com/svc%2Fwest/health"},
+ {"http://example.com/svc/%70rometheus", ""},
+ {"http://example.com/prometheus//", ""},
+ {"http://example.com/a//../prometheus", "http://example.com/a//../health"},
+ {"http://example.com/foo/metrics", ""},
+ {"http://example.com/prometheus-extra", ""},
+ }
+ for _, tt := range tests {
+ got, err := DefaultMicronautHealthURL(tt.metrics)
+ if err != nil {
+ t.Fatalf("DefaultMicronautHealthURL(%q) error = %v", tt.metrics, err)
+ }
+ if got != tt.want {
+ t.Fatalf("DefaultMicronautHealthURL(%q) = %q, want %q", tt.metrics, got, tt.want)
+ }
+ }
+}
+
+func TestMicronautAuthValidation(t *testing.T) {
+ for _, auth := range []*AuthConfig{
+ {Type: "bearer", Username: "u", Password: "p"},
+ {Type: "basic", Password: "p"},
+ {Type: "basic", Username: "u"},
+ {Type: "basic", Username: "u:x", Password: "p"},
+ } {
+ cfg := validConfig(TargetTypeMicronaut, "http://example.com/prometheus")
+ cfg.Targets[0].Auth = auth
+ if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "auth") {
+ t.Fatalf("invalid auth accepted: %v", err)
+ }
+ }
+}
diff --git a/internal/dashboard/static/dashboard.js b/internal/dashboard/static/dashboard.js
index 8562e18..1d705f0 100644
--- a/internal/dashboard/static/dashboard.js
+++ b/internal/dashboard/static/dashboard.js
@@ -703,6 +703,8 @@ function targetTypeHelp(value) {
return "Monitors a Spring Boot application through Actuator health and metrics endpoints.";
case "quarkus":
return "Monitors a Quarkus application through its metrics endpoint; SmallRye Health is used when available.";
+ case "micronaut":
+ return "Monitors a Micronaut application through its metrics endpoint; management health is used when available.";
case "statlite-metrics":
return "Monitors an app that exposes metrics in StatLite’s standard format.";
default:
diff --git a/internal/dashboard/static/dashboard.test.js b/internal/dashboard/static/dashboard.test.js
index ba37bc7..89328bb 100644
--- a/internal/dashboard/static/dashboard.test.js
+++ b/internal/dashboard/static/dashboard.test.js
@@ -27,6 +27,13 @@ test("targetTypeHelp describes the Quarkus metrics endpoint", () => {
);
});
+test("targetTypeHelp describes the Micronaut metrics endpoint and optional health", () => {
+ assert.equal(
+ dashboard.targetTypeHelp("micronaut"),
+ "Monitors a Micronaut application through its metrics endpoint; management health is used when available."
+ );
+});
+
test("runtimeHelp describes integration-dependent CPU and runtime memory", () => {
const expected = "CPU usage and runtime memory reported by the target. Their exact measurements depend on the integration and may differ from OS process CPU and total process memory.";
assert.equal(dashboard.runtimeHelp(), expected);
diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go
index e556848..1a7eb42 100644
--- a/internal/monitor/monitor.go
+++ b/internal/monitor/monitor.go
@@ -161,6 +161,8 @@ func (m *Monitor) IntegrationType() string {
switch m.collector.(type) {
case *collector.SpringActuatorCollector:
return config.TargetTypeSpring
+ case *collector.MicronautCollector:
+ return config.TargetTypeMicronaut
case *collector.QuarkusCollector:
return config.TargetTypeQuarkus
case *collector.StatliteMetricsCollector:
diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go
index 6e3cbbd..f02f8bb 100644
--- a/internal/monitor/monitor_test.go
+++ b/internal/monitor/monitor_test.go
@@ -1113,3 +1113,106 @@ func assertMonitorFloatPointer(t *testing.T, name string, got *float64, want flo
t.Fatalf("%s = %v, want %v", name, *got, want)
}
}
+
+func TestPollNowDetectsMicronautRestartWithoutNegativeCounterDelta(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+
+ var scrape int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ scrape++
+ if scrape == 1 {
+ _, _ = w.Write([]byte("process_start_time_seconds 1770000000\nprocess_cpu_usage 0.2\nhttp_server_requests_seconds_count{method=\"GET\",uri=\"/probe\",exception=\"none\",status=\"200\"} 100\n"))
+ return
+ }
+ _, _ = w.Write([]byte("process_start_time_seconds 1770000060\nprocess_cpu_usage 0.1\nhttp_server_requests_seconds_count{method=\"GET\",uri=\"/probe\",exception=\"none\",status=\"200\"} 2\n"))
+ }))
+ defer server.Close()
+ client, err := prometheus.NewClient(time.Second, prometheus.DefaultLimits, nil)
+ if err != nil {
+ t.Fatalf("NewClient() error = %v", err)
+ }
+ mon := newTestMonitor(t, store, collector.NewMicronautCollector("app", server.URL, client, nil))
+
+ first, err := mon.PollNow(context.Background())
+ if err != nil {
+ t.Fatalf("first PollNow() error = %v", err)
+ }
+ second, err := mon.PollNow(context.Background())
+ if err != nil {
+ t.Fatalf("second PollNow() error = %v", err)
+ }
+ if first.AppRunID == nil || second.AppRunID == nil || *first.AppRunID == *second.AppRunID {
+ t.Fatalf("app run ids = %v/%v, want distinct Micronaut runs", first.AppRunID, second.AppRunID)
+ }
+ if !hasEvent(second.Result.Events, EventTypeRestartDetected) {
+ t.Fatalf("events = %#v, want restart detection", second.Result.Events)
+ }
+ series, err := store.Series(context.Background(), "app", first.Result.PollStartedAt.Add(-time.Second), second.Result.PollFinishedAt.Add(time.Second))
+ if err != nil {
+ t.Fatalf("Series() error = %v", err)
+ }
+ if len(series.Points) != 2 || second.PollID != first.PollID+1 || mon.IntegrationType() != "micronaut" {
+ t.Fatalf("expected two logical Micronaut polls: %#v", series)
+ }
+ for _, point := range series.Points {
+ if point.Requests != nil && *point.Requests < 0 {
+ t.Fatalf("requests delta = %v after Micronaut restart, want nonnegative", *point.Requests)
+ }
+ }
+}
+
+func TestMicronautPollPersistsHealthIndependently(t *testing.T) {
+ store := openTestStore(t)
+ defer store.Close()
+ healthBody := `{"status":"UP","details":{"jdbc":{"status":"DOWN"}}}`
+ metricsFail := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/health" {
+ fmt.Fprint(w, healthBody)
+ return
+ }
+ if metricsFail {
+ http.Error(w, "failed", 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprint(w, "process_cpu_usage 0.2\n")
+ }))
+ defer server.Close()
+ pc, err := prometheus.NewClient(time.Second, prometheus.DefaultLimits, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hc, err := collector.NewMicronautHealthClient(server.URL+"/health", time.Second, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mon := newTestMonitor(t, store, collector.NewMicronautCollector("app", server.URL+"/prometheus", pc, hc))
+ for _, tt := range []struct {
+ body, app, db, status string
+ failed bool
+ }{
+ {healthBody, "UP", "DOWN", "ok", false},
+ {`{"status":"UNKNOWN"}`, "", "", "ok", false},
+ {`{"status":"UP","details":[]}`, "UP", "", "ok", false},
+ {`{"status":"DOWN","details":{"jdbc":{"status":"UP"}}}`, "DOWN", "UP", "error", true},
+ } {
+ healthBody, metricsFail = tt.body, tt.failed
+ polled, err := mon.PollNow(context.Background())
+ if (err != nil) != tt.failed {
+ t.Fatalf("poll error %v", err)
+ }
+ saved, err := store.LatestSnapshot(context.Background(), "app")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved.PollID != polled.PollID || saved.Status != tt.status || saved.Result.HealthStatus != tt.app || saved.Result.DBHealthStatus != tt.db {
+ t.Fatalf("persisted %#v result %#v", saved, saved.Result)
+ }
+ if !tt.failed && mon.Status().ConsecutivePollFailures != 0 {
+ t.Fatal("health warning failed metrics poll")
+ }
+ }
+}
From 13c63c4bf1678619f01c80043c561388e4738a72 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 11:43:10 -0700
Subject: [PATCH 03/13] Add typed Micronaut compatibility inspection (#56)
---
cmd/statlite/main.go | 15 +-
cmd/statlite/main_test.go | 54 ++++-
internal/inspect/inspect.go | 7 +-
internal/inspect/micronaut.go | 118 ++++++++++
internal/inspect/micronaut_test.go | 358 +++++++++++++++++++++++++++++
5 files changed, 543 insertions(+), 9 deletions(-)
create mode 100644 internal/inspect/micronaut.go
create mode 100644 internal/inspect/micronaut_test.go
diff --git a/cmd/statlite/main.go b/cmd/statlite/main.go
index 826ddce..602d87c 100644
--- a/cmd/statlite/main.go
+++ b/cmd/statlite/main.go
@@ -60,7 +60,7 @@ func runInspectWithTyped(args []string, stdout, stderr io.Writer, inspectApplica
inspectFlags.Usage = func() {
printInspectHelp(stderr)
}
- typed := inspectFlags.String("type", "", "inspect a specific target type (currently: quarkus)")
+ typed := inspectFlags.String("type", "", "inspect a specific target type (currently: quarkus, micronaut)")
name := inspectFlags.String("name", "", "target name (default: derived from type, host, and port)")
createPath := inspectFlags.String("create-config", "", "create a new configuration file at PATH")
addPath := inspectFlags.String("add-to-config", "", "append a target to an existing configuration file at PATH")
@@ -330,13 +330,14 @@ func noPollStartupMessages(hasStoredPoll bool) []string {
func printHelp(w io.Writer) {
fmt.Fprintf(w, `StatLite - tiny self-hosted metrics dashboard for small servers.
-Polls Spring Boot Actuator and StatLite self-monitoring endpoints, stores
-samples in local SQLite, and serves a localhost dashboard.
+Polls Spring Boot Actuator, Quarkus, Micronaut, and StatLite Metrics endpoints,
+stores samples in local SQLite, and serves a localhost dashboard.
Usage:
statlite [--config path] [--no-poll] [--raw-series]
statlite inspect
statlite inspect --type quarkus
+ statlite inspect --type micronaut
statlite inspect --create-config PATH
statlite inspect --add-to-config PATH
statlite --version
@@ -375,7 +376,9 @@ Prometheus/OpenMetrics endpoint. A base URL uses the conventional /q/metrics pat
Quote the URL when pasting it from a browser, especially if it contains ? or &.
Untyped inspection requires a base URL, so remove any query string or fragment first.
-Typed Quarkus inspection accepts a base URL or exact metrics endpoint URL.`)
+Typed Quarkus inspection accepts a base URL or exact metrics endpoint URL.
+Use --type micronaut with a base URL or exact Prometheus endpoint. Micronaut
+inspection checks compatibility without proving framework identity.`)
}
const configurationDocsURL = "https://github.com/PVRLabs/statlite/blob/main/docs/configuration.md"
@@ -457,6 +460,8 @@ func inspectionPresentation(targetType inspect.TargetType) (inspectionTargetPres
targetType: config.TargetTypeStatliteMetrics,
errorContext: "statlite-metrics target",
}, nil
+ case inspect.TargetMicronaut:
+ return inspectionTargetPresentation{displayName: "Micronaut Metrics", targetType: config.TargetTypeMicronaut, errorContext: "micronaut target"}, nil
case inspect.TargetQuarkus:
return inspectionTargetPresentation{
displayName: "Quarkus Metrics",
@@ -511,7 +516,7 @@ func printInspectFailure(w io.Writer, err error) {
case inspect.FailureMultiple:
fmt.Fprintln(w, "inspect: more than one supported integration was found")
case inspect.FailureIncompatible:
- fmt.Fprintf(w, "inspect: the configured Quarkus metrics endpoint is incompatible: %v\n", failure.Err)
+ fmt.Fprintf(w, "inspect: the configured metrics endpoint is incompatible: %v\n", failure.Err)
case inspect.FailureTypeUnsupported, inspect.FailureTypeUnavailable:
fmt.Fprintf(w, "inspect: %v\n", failure)
default:
diff --git a/cmd/statlite/main_test.go b/cmd/statlite/main_test.go
index f23e72c..1f71bfc 100644
--- a/cmd/statlite/main_test.go
+++ b/cmd/statlite/main_test.go
@@ -434,8 +434,8 @@ func TestRunInspectTypeErrorsUseAccurateUsageMessages(t *testing.T) {
targetType string
want string
}{
- {targetType: "prometheus", want: `unsupported inspection type "prometheus" (supported: quarkus)`},
- {targetType: "spring", want: `typed inspection type "spring" is not available (supported: quarkus)`},
+ {targetType: "prometheus", want: `unsupported inspection type "prometheus" (supported: quarkus, micronaut)`},
+ {targetType: "spring", want: `typed inspection type "spring" is not available (supported: quarkus, micronaut)`},
}
for _, tt := range tests {
t.Run(tt.targetType, func(t *testing.T) {
@@ -707,3 +707,53 @@ func TestRunExplicitAndMalformedConfigDoNotSuggestInspect(t *testing.T) {
t.Fatalf("stderr = %q, explicitly supplied default config must not get inspect suggestion", stderr.String())
}
}
+
+func TestRenderInspectionMicronautOutputRoundTripsExactEndpoint(t *testing.T) {
+ result := &inspect.Result{
+ TargetType: inspect.TargetMicronaut,
+ Endpoint: "http://localhost:9000/prometheus/?scope=app",
+ Status: inspect.CompatibilityCompatible,
+ Capabilities: []string{"process_cpu_usage", "jvm_heap_used_bytes"},
+ }
+
+ got, err := renderInspection(result)
+ if err != nil {
+ t.Fatalf("renderInspection() error = %v", err)
+ }
+ for _, want := range []string{
+ "Detected: Micronaut Metrics",
+ "Compatibility: compatible",
+ "type: micronaut",
+ "url: http://localhost:9000/prometheus/?scope=app",
+ "process_cpu_usage",
+ "jvm_heap_used_bytes",
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("renderInspection() = %q, missing %q", got, want)
+ }
+ }
+ assertSuggestedConfigLoads(t, got, config.TargetTypeMicronaut, "http://localhost:9000/prometheus/?scope=app")
+}
+
+func TestRunTypedMicronautInspectDispatchesOnlyToRequestedTarget(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ var untypedCalls, typedCalls int
+ code := runWithInspectors([]string{"inspect", "--type", "micronaut", "http://app.test/prometheus?scope=app"}, &stdout, &stderr,
+ func(context.Context, string) (*inspect.Result, error) {
+ untypedCalls++
+ return nil, errors.New("untyped inspector must not run")
+ },
+ func(_ context.Context, targetType inspect.TargetType, endpoint string) (*inspect.Result, error) {
+ typedCalls++
+ if targetType != inspect.TargetMicronaut || endpoint != "http://app.test/prometheus?scope=app" {
+ t.Fatalf("typed inspection arguments = %q, %q", targetType, endpoint)
+ }
+ return &inspect.Result{TargetType: inspect.TargetMicronaut, Endpoint: endpoint, Status: inspect.CompatibilityPartial, Capabilities: []string{"jvm_heap_used_bytes"}}, nil
+ })
+ if code != 0 || untypedCalls != 0 || typedCalls != 1 {
+ t.Fatalf("code=%d untyped=%d typed=%d stdout=%q stderr=%q", code, untypedCalls, typedCalls, stdout.String(), stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "Detected: Micronaut Metrics") || !strings.Contains(stdout.String(), "type: micronaut") || !strings.Contains(stdout.String(), "Compatibility: partial") {
+ t.Fatalf("stdout = %q, want typed Micronaut output", stdout.String())
+ }
+}
diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go
index ac34712..910fc13 100644
--- a/internal/inspect/inspect.go
+++ b/internal/inspect/inspect.go
@@ -34,6 +34,7 @@ type TargetType string
const (
TargetSpring TargetType = "spring"
TargetQuarkus TargetType = "quarkus"
+ TargetMicronaut TargetType = "micronaut"
TargetStatliteMetrics TargetType = "statlite-metrics"
)
@@ -125,10 +126,12 @@ func Inspect(ctx context.Context, targetType TargetType, rawURL string) (*Result
return inspectSpring(ctx, rawURL)
case TargetQuarkus:
return inspectQuarkus(ctx, rawURL)
+ case TargetMicronaut:
+ return inspectMicronaut(ctx, rawURL)
default:
return nil, &Failure{
Kind: FailureTypeUnsupported,
- Err: fmt.Errorf("unsupported inspection type %q (supported: quarkus)", targetType),
+ Err: fmt.Errorf("unsupported inspection type %q (supported: quarkus, micronaut)", targetType),
}
}
}
@@ -139,7 +142,7 @@ func Inspect(ctx context.Context, targetType TargetType, rawURL string) (*Result
func inspectSpring(context.Context, string) (*Result, error) {
return nil, &Failure{
Kind: FailureTypeUnavailable,
- Err: errors.New("typed inspection type \"spring\" is not available (supported: quarkus)"),
+ Err: errors.New("typed inspection type \"spring\" is not available (supported: quarkus, micronaut)"),
}
}
diff --git a/internal/inspect/micronaut.go b/internal/inspect/micronaut.go
new file mode 100644
index 0000000..750be15
--- /dev/null
+++ b/internal/inspect/micronaut.go
@@ -0,0 +1,118 @@
+package inspect
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/pvrlabs/statlite/internal/collector"
+ "github.com/pvrlabs/statlite/internal/prometheus"
+ "github.com/pvrlabs/statlite/internal/urlshape"
+)
+
+func inspectMicronaut(parent context.Context, rawURL string) (*Result, error) {
+ endpoints, err := micronautInspectionEndpoints(rawURL)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithTimeout(parent, defaultTimeout)
+ defer cancel()
+
+ var result *Result
+ for index, endpoint := range endpoints {
+ result, err = inspectMicronautEndpoint(ctx, endpoint, nil)
+ if err == nil || index == len(endpoints)-1 || !isMicronautConclusiveMiss(err) {
+ return result, err
+ }
+ }
+ return result, err
+}
+
+func inspectMicronautEndpoint(ctx context.Context, endpoint string, transport http.RoundTripper) (*Result, error) {
+ client, err := prometheus.NewClientWithTransport(defaultTimeout, prometheus.DefaultLimits, nil, transport)
+ if err != nil {
+ return nil, &Failure{Kind: FailureIncomplete, Err: err}
+ }
+ inspection, err := collector.InspectMicronaut(ctx, endpoint, client)
+ if err != nil {
+ return nil, micronautFailure(err)
+ }
+ status := CompatibilityCompatible
+ if inspection.Status == "partial" {
+ status = CompatibilityPartial
+ }
+ return &Result{
+ TargetType: TargetMicronaut,
+ Endpoint: endpoint,
+ Capabilities: inspection.Capabilities,
+ Warnings: inspection.Warnings,
+ Status: status,
+ }, nil
+}
+
+func micronautInspectionEndpoints(raw string) ([]string, error) {
+ if strings.TrimSpace(raw) != raw || raw == "" {
+ return nil, fmt.Errorf("Micronaut metrics URL must be a nonblank absolute URL without surrounding whitespace")
+ }
+ parsed, err := urlshape.ParseHTTP(raw)
+ if err != nil {
+ return nil, fmt.Errorf("Micronaut metrics URL: %w", err)
+ }
+ if parsed.User != nil {
+ return nil, fmt.Errorf("Micronaut metrics URL must not include user information")
+ }
+ if (parsed.Path == "" || parsed.Path == "/") && parsed.RawQuery == "" && !parsed.ForceQuery {
+ parsed.Path = "/prometheus"
+ parsed.RawPath = ""
+ return []string{parsed.String()}, nil
+ }
+ endpoints := []string{raw}
+ if parsed.RawQuery == "" && !parsed.ForceQuery && !strings.HasSuffix(strings.TrimSuffix(parsed.EscapedPath(), "/"), "/prometheus") {
+ fallback := *parsed
+ escaped := strings.TrimSuffix(parsed.EscapedPath(), "/") + "/prometheus"
+ fallback.Path, err = url.PathUnescape(escaped)
+ if err != nil {
+ return nil, err
+ }
+ fallback.RawPath = escaped
+ endpoints = append(endpoints, fallback.String())
+ }
+ return endpoints, nil
+}
+
+func isMicronautConclusiveMiss(err error) bool {
+ var failure *Failure
+ return errors.As(err, &failure) && (failure.Kind == FailureIncompatible || isMicronautEndpointMiss(failure.Err))
+}
+
+func micronautFailure(err error) *Failure {
+ if errors.Is(err, collector.ErrMicronautIncompatible) {
+ return &Failure{Kind: FailureIncompatible, Err: err}
+ }
+ var scrapeErr *prometheus.Error
+ if errors.As(err, &scrapeErr) {
+ switch scrapeErr.Class {
+ case prometheus.FailureAuthentication:
+ return &Failure{Kind: FailureAuthRequired, Err: err}
+ case prometheus.FailureTransport:
+ if errors.Is(err, context.DeadlineExceeded) {
+ return &Failure{Kind: FailureIncomplete, Err: err}
+ }
+ return &Failure{Kind: FailureUnreachable, Err: err}
+ case prometheus.FailureInvalidURL:
+ return &Failure{Kind: FailureIncomplete, Err: err}
+ default:
+ return &Failure{Kind: FailureIncomplete, Err: err}
+ }
+ }
+ return &Failure{Kind: FailureIncomplete, Err: err}
+}
+
+func isMicronautEndpointMiss(err error) bool {
+ var scrapeErr *prometheus.Error
+ return errors.As(err, &scrapeErr) && scrapeErr.Class == prometheus.FailureNotFound
+}
diff --git a/internal/inspect/micronaut_test.go b/internal/inspect/micronaut_test.go
new file mode 100644
index 0000000..f476c14
--- /dev/null
+++ b/internal/inspect/micronaut_test.go
@@ -0,0 +1,358 @@
+package inspect
+
+import (
+ "context"
+ "fmt"
+ "github.com/pvrlabs/statlite/internal/collector"
+ "github.com/pvrlabs/statlite/internal/prometheus"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTypedMicronautInspectionUsesExactEndpointAndOneBoundedScrape(t *testing.T) {
+ var requests int
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if r.URL.Path != "/prometheus/" || r.URL.RawQuery != "scope=app" {
+ t.Fatalf("request URL = %s, want exact Micronaut endpoint", r.URL)
+ }
+ if !strings.Contains(r.Header.Get("Accept"), "openmetrics-text") {
+ t.Fatalf("Accept = %q, want Prometheus/OpenMetrics negotiation", r.Header.Get("Accept"))
+ }
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ fmt.Fprint(w, `http_server_requests_seconds_count{method="GET",exception="none",status="200",uri="/"} 2
+http_server_requests_seconds_sum{method="GET",exception="none",status="200",uri="/"} 0.5
+process_cpu_usage 0.25
+jvm_memory_used_bytes{area="heap",id="eden"} 1024
+process_start_time_seconds 1770000000
+process_uptime_seconds 12
+`)
+ }))
+ defer server.Close()
+
+ endpoint := server.URL + "/prometheus/?scope=app"
+ result, err := Inspect(context.Background(), TargetMicronaut, endpoint)
+ if err != nil {
+ t.Fatalf("Inspect() error = %v", err)
+ }
+ if requests != 1 {
+ t.Fatalf("requests = %d, want one bounded scrape", requests)
+ }
+ if result.TargetType != TargetMicronaut || result.Endpoint != endpoint || result.Status != CompatibilityCompatible {
+ t.Fatalf("result = %#v, want exact compatible Micronaut result", result)
+ }
+ want := []string{
+ "http_requests_total", "http_404_total", "http_4xx_total", "http_5xx_total",
+ "http_request_time_total_seconds", "process_cpu_usage", "jvm_heap_used_bytes",
+ "process_start_time", "process_uptime",
+ }
+ if strings.Join(result.Capabilities, ",") != strings.Join(want, ",") {
+ t.Fatalf("capabilities = %v, want %v", result.Capabilities, want)
+ }
+ if len(result.Warnings) != 0 {
+ t.Fatalf("warnings = %v, want none", result.Warnings)
+ }
+}
+
+func TestTypedMicronautInspectionDiscoversConventionalEndpointFromBaseURL(t *testing.T) {
+ var path string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path = r.URL.Path
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ fmt.Fprint(w, "process_cpu_usage 0.25\n")
+ }))
+ defer server.Close()
+
+ result, err := Inspect(context.Background(), TargetMicronaut, server.URL)
+ if err != nil {
+ t.Fatalf("Inspect() error = %v", err)
+ }
+ if path != "/prometheus" || result.Endpoint != server.URL+"/prometheus" {
+ t.Fatalf("path = %q, result = %#v, want discovered conventional endpoint", path, result)
+ }
+}
+
+func TestTypedMicronautInspectionFallsBackFromContextRootToConventionalEndpoint(t *testing.T) {
+ var paths []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ if r.URL.Path == "/service/prometheus" {
+ fmt.Fprint(w, "process_cpu_usage 0.25\n")
+ return
+ }
+ fmt.Fprint(w, "unrelated_metric 1\n")
+ }))
+ defer server.Close()
+
+ result, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/service")
+ if err != nil {
+ t.Fatalf("Inspect() error = %v", err)
+ }
+ if got := strings.Join(paths, ","); got != "/service,/service/prometheus" {
+ t.Fatalf("paths = %q, want exact attempt followed by conventional endpoint", got)
+ }
+ if result.Endpoint != server.URL+"/service/prometheus" {
+ t.Fatalf("endpoint = %q, want discovered context-root endpoint", result.Endpoint)
+ }
+}
+
+func TestTypedMicronautInspectionDoesNotFallbackAfterHTTPFailure(t *testing.T) {
+ var paths []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
+ }))
+ defer server.Close()
+
+ _, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/service")
+ assertFailureKind(t, err, FailureIncomplete)
+ if got := strings.Join(paths, ","); got != "/service" {
+ t.Fatalf("paths = %q, want no conventional fallback after HTTP 503", got)
+ }
+}
+
+func TestTypedMicronautInspectionReportsPartialCapabilitiesAndGeneratesNoProbeState(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ fmt.Fprint(w, "jvm_memory_used_bytes{area=\"heap\"} 1024\nprocess_uptime_seconds -1\n")
+ }))
+ defer server.Close()
+
+ result, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/prometheus")
+ if err != nil {
+ t.Fatalf("Inspect() error = %v", err)
+ }
+ if result.Status != CompatibilityPartial || strings.Join(result.Capabilities, ",") != "jvm_heap_used_bytes" {
+ t.Fatalf("result = %#v, want partial heap-only result", result)
+ }
+ if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "process uptime") {
+ t.Fatalf("warnings = %v, want focused partial warning", result.Warnings)
+ }
+}
+
+func TestTypedMicronautInspectionRejectsUnrelatedMalformedOversizedAndAuthResponses(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ body string
+ want FailureKind
+ }{
+ {name: "unrelated", status: http.StatusOK, body: "unrelated_metric 1\n", want: FailureIncompatible},
+ {name: "malformed", status: http.StatusOK, body: "process_cpu_usage nope\n", want: FailureIncomplete},
+ {name: "auth", status: http.StatusUnauthorized, body: "unauthorized\n", want: FailureAuthRequired},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ w.WriteHeader(tt.status)
+ _, _ = io.WriteString(w, tt.body)
+ }))
+ defer server.Close()
+
+ _, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/prometheus")
+ assertFailureKind(t, err, tt.want)
+ })
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4")
+ _, _ = io.WriteString(w, strings.Repeat("x", 1<<20+1))
+ }))
+ defer server.Close()
+ _, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/prometheus")
+ assertFailureKind(t, err, FailureIncomplete)
+}
+
+func TestTypedMicronautInspectionRejectsUnsafeEndpointForms(t *testing.T) {
+ for _, raw := range []string{
+ "ftp://app.test/prometheus",
+ "http://user:secret@app.test/prometheus",
+ "http://app.test/prometheus#fragment",
+ "http://app.test/prometheus#",
+ "http://app.test/prometheus?scope=app#",
+ "http://app.test:0/prometheus",
+ "http://app.test:65536/prometheus",
+ "http:prometheus",
+ " http://app.test/prometheus",
+ } {
+ t.Run(raw, func(t *testing.T) {
+ if _, err := Inspect(context.Background(), TargetMicronaut, raw); err == nil {
+ t.Fatalf("Inspect(%q) error = nil", raw)
+ }
+ })
+ }
+}
+
+func TestMicronautInspectionEndpointsPreservesCustomizedEndpointAndQuery(t *testing.T) {
+ const endpoint = "http://app.test/custom/metrics?scope=app"
+ got, err := micronautInspectionEndpoints(endpoint)
+ if err != nil {
+ t.Fatalf("micronautInspectionEndpoints() error = %v", err)
+ }
+ if len(got) != 1 || got[0] != endpoint {
+ t.Fatalf("endpoints = %q, want only exact endpoint %q", got, endpoint)
+ }
+}
+
+func TestTypedMicronautInspectionReportsUnreachableEndpoint(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ endpoint := server.URL + "/prometheus"
+ server.Close()
+
+ _, err := Inspect(context.Background(), TargetMicronaut, endpoint)
+ assertFailureKind(t, err, FailureUnreachable)
+}
+
+func TestMicronautEndpointCandidates(t *testing.T) {
+ for _, tt := range []struct{ suffix, want string }{
+ {"", "/prometheus"}, {"/", "/prometheus"}, {"/?", "/?"},
+ {"/svc%2Fwest/", "/svc%2Fwest/,/svc%2Fwest/prometheus"},
+ {"/svc/%70rometheus", "/svc/%70rometheus,/svc/%70rometheus/prometheus"},
+ {"/svc/prometheus/", "/svc/prometheus/"},
+ {"/svc//", "/svc//,/svc//prometheus"},
+ {"/svc/../custom", "/svc/../custom,/svc/../custom/prometheus"},
+ {"/custom?x=1", "/custom?x=1"},
+ {"/prometheus?scope=%23", "/prometheus?scope=%23"},
+ {"/custom%23metrics?scope=app", "/custom%23metrics?scope=app"},
+ } {
+ t.Run(tt.suffix, func(t *testing.T) {
+ endpoints, err := micronautInspectionEndpoints("http://app.test" + tt.suffix)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range endpoints {
+ endpoints[i] = strings.TrimPrefix(endpoints[i], "http://app.test")
+ }
+ if strings.Join(endpoints, ",") != tt.want {
+ t.Fatalf("endpoints = %v, want %s", endpoints, tt.want)
+ }
+ })
+ }
+}
+
+func TestMicronautFallbackOnlyAfterConclusiveMiss(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ status int
+ content, body string
+ requests int
+ }{
+ {"404", 404, "text/plain", "missing", 2}, {"410", 410, "text/plain", "gone", 2},
+ {"incompatible", 200, "text/plain", "unrelated 1\n", 2},
+ {"malformed", 200, "text/plain", "process_cpu_usage nope\n", 1},
+ {"html", 200, "text/html", "", 1},
+ {"forbidden", 403, "text/plain", "denied", 1},
+ {"oversized", 200, "text/plain", strings.Repeat("# padding\n", 500000), 1},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests++
+ if requests == 2 {
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprint(w, "process_cpu_usage 0.2\n")
+ return
+ }
+ w.Header().Set("Content-Type", tt.content)
+ w.WriteHeader(tt.status)
+ fmt.Fprint(w, tt.body)
+ }))
+ defer server.Close()
+ result, err := Inspect(context.Background(), TargetMicronaut, server.URL+"/svc%2Fwest/")
+ if requests != tt.requests {
+ t.Fatalf("requests = %d", requests)
+ }
+ if tt.requests == 2 {
+ if err != nil || result.Endpoint != server.URL+"/svc%2Fwest/prometheus" {
+ t.Fatalf("result=%v err=%v", result, err)
+ }
+ } else if err == nil {
+ t.Fatal("expected failure")
+ }
+ })
+ }
+}
+
+func TestMicronautInspectionCancelledDoesNotFallback(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := Inspect(ctx, TargetMicronaut, "http://example.test/context")
+ if err == nil {
+ t.Fatal("expected cancelled inspection")
+ }
+}
+
+func TestUntypedInspectionDoesNotProbeMicronaut(t *testing.T) {
+ var paths []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ if r.URL.Path == "/prometheus" {
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprint(w, `process_cpu_usage 0.25
+http_server_requests_seconds_count{method="GET",status="200",uri="/",exception="none"} 1
+`)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+ _, err := Application(context.Background(), server.URL)
+ assertFailureKind(t, err, FailureUnrecognized)
+ for _, p := range paths {
+ if p == "/prometheus" {
+ t.Fatal("unexpected Micronaut probe")
+ }
+ }
+}
+
+func TestMicronautInspectionDeadlineStopsFallback(t *testing.T) {
+ requests := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++; <-r.Context().Done() }))
+ defer server.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+ _, err := Inspect(ctx, TargetMicronaut, server.URL+"/context")
+ assertFailureKind(t, err, FailureIncomplete)
+ if requests != 1 {
+ t.Fatalf("requests=%d", requests)
+ }
+}
+
+func TestMicronautInspectionAgreesWithCollection(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprint(w, "process_cpu_usage 0.2\nprocess_uptime_seconds -1\n")
+ }))
+ defer server.Close()
+ endpoint := server.URL + "/prometheus"
+ inspection, err := Inspect(context.Background(), TargetMicronaut, endpoint)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client, err := prometheus.NewClientWithTransport(defaultTimeout, prometheus.DefaultLimits, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := collector.NewMicronautCollector("test", endpoint, client, nil).Collect(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var keys, warnings []string
+ for _, sample := range result.Samples {
+ keys = append(keys, sample.Key)
+ }
+ for _, event := range result.Events {
+ warnings = append(warnings, event.Message)
+ }
+ if !reflect.DeepEqual(keys, inspection.Capabilities) || !reflect.DeepEqual(warnings, inspection.Warnings) || inspection.Status != CompatibilityPartial {
+ t.Fatalf("inspection=%v collection=%v", inspection, result)
+ }
+}
From 1e5af45df5098d6fbe19098bbe741380cd3a7ebc Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 13:25:21 -0700
Subject: [PATCH 04/13] docs: document certified Micronaut support (#56)
---
README.md | 15 +++-
cmd/statlite/main.go | 8 +-
docs/api.md | 2 +-
docs/configuration.md | 167 +++++++++++++++++++++++++++++++++++--
docs/integrations.md | 37 +++++++-
docs/monitoring-options.md | 4 +-
docs/product.md | 16 ++--
examples/README.md | 1 +
examples/micronaut.yaml | 13 +++
9 files changed, 239 insertions(+), 24 deletions(-)
create mode 100644 examples/micronaut.yaml
diff --git a/README.md b/README.md
index b643b79..85f8eaf 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://github.com/PVRLabs/statlite/releases)
[](go.mod)
-[](docs/integrations.md)
+[](docs/integrations.md)
[](https://github.com/PVRLabs/statlite/actions/workflows/test.yml)
[](LICENSE)
@@ -21,7 +21,7 @@ charts, without requiring Prometheus or Grafana.
Main application dashboard for the Spring target.
-StatLite supports Spring Boot and Quarkus integrations, and other
+StatLite supports Spring Boot, Quarkus, and Micronaut integrations, and other
applications through [a small, fixed JSON metrics endpoint](docs/statlite-metrics-v1.md).
It collects traffic, latency, CPU, memory, optional application health, and
optional host metrics. Metrics and history stay on your server, without
@@ -114,11 +114,13 @@ For other supported frameworks, select the type explicitly when needed:
```bash
statlite inspect --type quarkus 'http://localhost:9000'
+statlite inspect --type micronaut 'http://localhost:8080'
```
Inspection checks conventional supported endpoints and is bounded and
-read-only. For untyped discovery, start with a base HTTP or HTTPS URL without a
-query string or fragment.
+read-only. Micronaut requires `--type micronaut`; inspection validates its
+supported contract without proving framework identity. For untyped discovery, start with
+a base HTTP or HTTPS URL without a query string or fragment.
See [Configuration](docs/configuration.md) for exact endpoint forms, discovery
limits, authentication limitations, all settings, and manual target
@@ -145,6 +147,11 @@ health.
- **Quarkus Micrometer:** Collects bounded request, latency, CPU, heap, process,
and restart concepts from an exact Prometheus/OpenMetrics endpoint. SmallRye
Health is an optional capability when the application publishes it.
+- **Micronaut Micrometer:** Collects the existing request, duration, CPU, heap,
+ process, and restart concepts from an exact configured Prometheus endpoint,
+ conventionally `/prometheus`. Management health is optional; database health
+ requires visible JDBC aggregate status.
+ See the [certified setup](docs/configuration.md#micronaut-micrometer-metrics).
- **[StatLite Metrics v1](docs/statlite-metrics-v1.md):** A small, fixed JSON
endpoint that applications in any language or framework can implement. See
the [direct integration guides](docs/integrate/) for FastAPI, Express,
diff --git a/cmd/statlite/main.go b/cmd/statlite/main.go
index 602d87c..2b26050 100644
--- a/cmd/statlite/main.go
+++ b/cmd/statlite/main.go
@@ -360,6 +360,7 @@ func printInspectHelp(w io.Writer) {
Example:
statlite inspect 'http://localhost:8080'
+ statlite inspect --type micronaut 'http://localhost:8080/prometheus'
statlite inspect 'http://localhost:8080' --create-config ./statlite.yaml
statlite inspect 'http://localhost:8080' --add-to-config ./statlite.yaml
@@ -377,8 +378,11 @@ Prometheus/OpenMetrics endpoint. A base URL uses the conventional /q/metrics pat
Quote the URL when pasting it from a browser, especially if it contains ? or &.
Untyped inspection requires a base URL, so remove any query string or fragment first.
Typed Quarkus inspection accepts a base URL or exact metrics endpoint URL.
-Use --type micronaut with a base URL or exact Prometheus endpoint. Micronaut
-inspection checks compatibility without proving framework identity.`)
+Use --type micronaut with a base URL or exact Prometheus endpoint. A root base
+URL resolves to /prometheus; non-root paths allow one context fallback after a
+conclusive miss. URLs with a query are exact endpoints. Micronaut inspection
+checks compatibility without proving framework identity; untyped inspection
+does not recognize Micronaut.`)
}
const configurationDocsURL = "https://github.com/PVRLabs/statlite/blob/main/docs/configuration.md"
diff --git a/docs/api.md b/docs/api.md
index 2197940..d791be0 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -47,7 +47,7 @@ application and dependency health for the selected target.
| Field | Meaning |
|---|---|
| `target` | Configured target name. |
-| `type` | Canonical integration type, such as `spring`, `quarkus`, or `statlite-metrics`. |
+| `type` | Canonical integration type, such as `spring`, `quarkus`, `micronaut`, or `statlite-metrics`. |
| `collection_status` | `not_polled` before any poll, `ok` after a successful latest poll, or `error` when the latest poll failed. |
| `last_poll_at` | Completion time of the latest collection attempt, or `null`. |
| `last_successful_poll_at` | Completion time of the latest successful collection, or `null`. |
diff --git a/docs/configuration.md b/docs/configuration.md
index 3a6487e..1b386a1 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,7 +1,8 @@
# Configuration
-StatLite supports [Spring Boot Actuator](#spring-boot-actuator) and
-[Quarkus Micrometer metrics](#quarkus-micrometer-metrics) targets. Applications
+StatLite supports [Spring Boot Actuator](#spring-boot-actuator),
+[Quarkus Micrometer metrics](#quarkus-micrometer-metrics), and
+[Micronaut Micrometer metrics](#micronaut-micrometer-metrics) targets. Applications
can also expose the fixed [StatLite Metrics v1](statlite-metrics-v1.md) JSON
profile, with [ready-to-use integration guides](integrate/) for FastAPI,
Express, Django, Go `net/http`, and Gin. Depending on the integration and
@@ -49,7 +50,8 @@ matrix and first-class targets. For applications using StatLite Metrics v1,
see [Integrate an application with StatLite](integrate/) for framework guides
and examples. These integrations expose a `/statlite/metrics` endpoint;
configuration only tells StatLite where to poll it. See `examples/` for starter
-templates (Actuator, Quarkus, StatLite Metrics, multi-target, self-monitoring)
+templates (Actuator, Micronaut, StatLite Metrics, multi-target, self-monitoring)
+and the Quarkus demo config
and `examples/spring-actuator-demo/` for a standalone Spring Boot demo app.
## Discover a target with `inspect`
@@ -75,6 +77,27 @@ to `/q/metrics`. A non-root URL is tried first as an exact endpoint and then,
after a conclusive miss, with `/q/metrics` appended. A URL containing a query
string is always an exact endpoint and is preserved.
+Micronaut requires explicit typed inspection:
+
+```bash
+statlite inspect --type micronaut 'http://localhost:8080'
+statlite inspect --type micronaut 'http://localhost:8080/prometheus'
+statlite inspect --type micronaut 'http://localhost:8080/service'
+```
+
+A root base URL resolves to `/prometheus`. A non-root path is tried exactly;
+after HTTP 404/410 or parsed incompatible metrics, one context-path fallback
+appends `/prometheus`. Conventional `/prometheus` paths and URLs with a query
+(including a bare `?`) are exact and have no fallback. Escaped paths and queries
+are preserved. Authentication failures, malformed responses, timeouts, and other
+inconclusive failures stop resolution. Inspection has a five-second overall
+deadline and at most two scrapes, each with at most three same-origin redirects.
+It uses the runtime metrics evaluator, reports `partial` for compatible scrapes
+with warnings, and does not request health. Compatibility does not prove
+Micronaut identity. Untyped inspection adds no Micronaut probe or inference from
+arbitrary Micrometer exposition. Inspection has no new authentication options;
+configure authenticated targets manually with the shared Basic Auth block.
+
On success, plain `inspect` prints the recognized capabilities, a minimal
configuration, and commands to create or add it. The suggested target gets a
stable `--` name. Override it with `--name` if needed;
@@ -178,7 +201,7 @@ targets with a compatible stored baseline use the configured interval normally.
At least one target is required. Names must be unique.
Target URLs must use `http://` or `https://`, include a host, and omit
-fragments; only Quarkus and StatLite Metrics URLs may include queries, and
+fragments; Quarkus, Micronaut, and StatLite Metrics URLs may include queries, and
canonical target URLs do not support embedded credentials.
### Spring Boot Actuator
@@ -278,7 +301,7 @@ targets:
health_url: "http://localhost:9000/manage/health"
```
-`health_url` is optional and accepted only for Quarkus targets. The target's
+`health_url` is optional and accepted for Quarkus and Micronaut targets. The target's
Basic Auth configuration applies to both metrics and health requests.
If the derived `/q/health` endpoint returns `404`, StatLite treats SmallRye
@@ -305,6 +328,133 @@ Host resources are not inferred or populated. Missing optional concepts produce
partial data; an endpoint without a usable required runtime family is
incompatible.
+### Micronaut Micrometer metrics
+
+```yaml
+targets:
+ - name: "orders"
+ type: "micronaut"
+ url: "http://localhost:8080/prometheus"
+```
+
+Collection requests the exact `url`, including its path, trailing slash, and
+query, without endpoint discovery. Omitted `type` still defaults to Spring.
+Spring-only `actuator_base_url`, `metrics_source`, and `collect_host_metrics`
+are rejected for Micronaut, including explicit empty/false values. Host metrics
+are not inferred from application metrics.
+
+The certified dependency graph uses platform parent 4.9.2, Micronaut 4.9.9,
+Micronaut Micrometer 5.12.0, and Micrometer 1.15.0. In an application using this
+platform, include these dependencies:
+
+```xml
+
+ io.micronaut
+ micronaut-management
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-core
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-registry-prometheus
+
+```
+
+Enable metrics and permit access to the Prometheus endpoint in the application's
+configuration. The minimal certified graph uses properties:
+
+```properties
+micronaut.metrics.enabled=true
+endpoints.prometheus.sensitive=false
+```
+
+Certification runs with Eclipse Temurin 25.0.4.1+1-LTS and Java 17 fixture
+bytecode. These are tested versions, not a claim that every runtime or
+Micrometer setup is compatible. No Prometheus server or Grafana is required.
+Removing management from this graph removes both `/prometheus` and `/health`.
+To retain metrics with absent health, keep management installed and set
+`endpoints.health.enabled=false`.
+
+Only existing StatLite concepts are normalized:
+
+| StatLite sample | Micronaut source |
+| --- | --- |
+| `http_requests_total` | Sum `http_server_requests_seconds_count` |
+| `http_404_total` | Count with exact status 404 |
+| `http_4xx_total` | Count with status 400–499, including 404 |
+| `http_5xx_total` | Count with status 500–599 |
+| `http_request_time_total_seconds` | Sum `_sum` with matching accepted count/sum identities |
+| `process_cpu_usage` | Finite `process_cpu_usage` ratio from 0 to 1 |
+| `jvm_heap_used_bytes` | Sum nonnegative `jvm_memory_used_bytes{area="heap"}` |
+| `process_start_time` | Valid `process_start_time_seconds`, also used for restart identity |
+| `process_uptime` | Finite nonnegative `process_uptime_seconds` |
+
+Both HTTP count and sum require nonempty `method`, `status`, `uri`, and
+`exception` labels. Status is exactly three ASCII digits from 100 through 599.
+Count/sum matching uses the entire source label identity; extra labels take part
+in matching but are not stored. Matching state is bounded to 20,000 combined
+identities. Invalid or duplicate series, mismatches, and overflowing aggregates
+omit affected concepts and record focused warnings. Independent valid concepts
+remain usable. Missing optional families do not warn. Compatibility requires a
+valid CPU, heap, or process-start concept; uptime or HTTP metrics alone are
+insufficient. Runtime-only idle exposition is compatible.
+
+HTTP timers are absent before the first completed request at startup and
+restart, so HTTP samples remain unavailable until meters exist. Once present,
+counters include application and management requests, including scrapes,
+health requests, and inspection probes. A scrape does not include its own
+unfinished request. StatLite stores raw cumulative counters and derives
+nonnegative deltas at query time; process-start changes retain the existing
+restart boundary semantics. Routes, exceptions, arbitrary labels, histogram
+buckets, and percentiles are not stored.
+
+Health is an independent optional request after the metrics attempt. For a
+literal `/prometheus` suffix with at most one trailing slash, StatLite derives
+same-origin `/health`, preserves the escaped context prefix, and removes the
+query. A custom metrics path requires an explicit override for health:
+
+```yaml
+targets:
+ - name: "orders"
+ type: "micronaut"
+ url: "http://localhost:8080/manage/metrics"
+ health_url: "http://localhost:8080/manage/health"
+```
+
+`health_url` retains its exact path/query and may specify a different origin.
+The target's Basic Auth applies to both endpoints. Redirects stay within each
+endpoint's origin, and requests share the configured poll timeout and existing
+body limits. When a health endpoint is configured or derived, StatLite attempts
+at most one logical health request per poll unless derived health is cached
+absent.
+
+Application health requires a valid aggregate UP/DOWN `status` over HTTP 200 or
+503. Unknown status, malformed payloads, or fetch errors leave health unavailable
+and warn without discarding good metrics. Database health requires visible
+`details.jdbc.status` with UP/DOWN. Absent or hidden details leave DB health
+unavailable; invalid optional JDBC details warn while preserving valid app
+health. Nested datasource details are ignored, and overall app health never
+fills in DB health. The certified JDBC setup uses `micronaut-jdbc-hikari` 6.2.1
+and H2 2.3.232. Exposing JDBC details to anonymous clients requires:
+
+```properties
+endpoints.health.details-visible=ANONYMOUS
+```
+
+The default authenticated visibility hides those details from anonymous
+requests. Health absence never fabricates stored application or DB status.
+Successful collection with unavailable explicit health can still display
+operational `UP` on the dashboard, with its existing collection-based hint.
+
+An initial derived-health 404 with compatible metrics is quiet and cached until
+a detectable process-start change or collector recreation. There is no periodic
+reprobe. Enabling health without a detectable restart requires collector
+recreation to discover it. After health was available, its loss warns and is
+retried each poll. Explicit overrides always probe and warn on 404. These rules
+preserve usable metrics and avoid carrying forward stale health values.
+
### Basic Auth
```yaml
@@ -314,9 +464,9 @@ auth:
password: "${STATLITE_ACTUATOR_PASSWORD}"
```
-Only `basic` is supported. The same `auth` block applies to Quarkus
-metrics and health endpoints as well as Spring endpoints. Prefer environment variables for
-credentials, so they are not stored in plaintext YAML. Export them before
+Only `basic` is supported. The same `auth` block applies to Quarkus and
+Micronaut metrics and health endpoints as well as Spring endpoints. Prefer
+environment variables for credentials, so they are not stored in plaintext YAML. Export them before
starting StatLite (or set them with your service manager):
```bash
@@ -409,6 +559,7 @@ Selected target and time range are stored in the query string, so you can bookma
|------|---------|
| `statlite.yaml` (repo root) | Default Quick Start that monitors StatLite itself |
| `examples/actuator.yaml` | Single Spring Boot Actuator target with Basic Auth placeholders |
+| `examples/micronaut.yaml` | Exact Micronaut Prometheus endpoint with optional management health |
| `examples/statlite.yaml` | Monitor another StatLite instance with `statlite-metrics` |
| `examples/multi-target.yaml` | Illustrative multi-target mix (Actuator + StatLite Metrics + self) |
| `examples/quarkus-metrics-demo/` | Pinned Quarkus Micrometer metrics fixture and traffic recipe |
diff --git a/docs/integrations.md b/docs/integrations.md
index e879d32..a19593b 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -14,6 +14,7 @@ not a generic Prometheus scraper or metrics database.
| Spring Boot Actuator | Supported | Actuator JSON | `/actuator` management base URL | Actuator health is the normal Spring health source; application request, JVM, process, and optional host concepts are normalized into StatLite's fixed vocabulary. |
| Spring Micrometer Prometheus | Supported | Prometheus/OpenMetrics exposition | Configured Prometheus endpoint | This is a Spring source option, not a generic `prometheus` target. |
| Quarkus 3.39.x | Supported | Micrometer Prometheus/OpenMetrics exposition; optional SmallRye Health | Conventional `/q/metrics`; optional `/q/health` | Explicit `quarkus` target; datasource health is normalized when published. |
+| Micronaut 4.9.9 (certified setup) | Supported | Micrometer 1.15.0 Prometheus exposition; optional management health | Exact `/prometheus`; optional `/health` | Explicit `micronaut` target; JDBC health requires visible aggregate details. |
| StatLite Metrics v1 | Supported | Fixed `statlite-metrics/v1` response | `/statlite/metrics` | Fixed producer contract for StatLite and compatible applications. See the [direct integration guides](integrate/). |
Support means that the integration has an owned endpoint and source contract,
@@ -76,7 +77,7 @@ established `/q/metrics` location; it does not identify arbitrary Micrometer
exposition as Quarkus. Basic Auth uses the shared `auth.type: basic`
configuration for both endpoints.
-Spring Boot and Quarkus memory is JVM heap used. It is runtime-managed
+Spring Boot, Quarkus, and Micronaut memory is JVM heap used. It is runtime-managed
application memory, not process RSS, container memory, or a configured maximum
heap size.
@@ -85,6 +86,40 @@ The public pinned fixture is
Quarkus 3.39.1, Java 21 LTS, the Micrometer Prometheus registry, and SmallRye
Health.
+## Micronaut
+
+Micronaut uses an explicit `type: micronaut` target and the exact metrics
+endpoint, conventionally `http://localhost:8080/prometheus`. The certified
+setup uses Micronaut 4.9.9 (platform parent 4.9.2), Micronaut Micrometer 5.12.0,
+Micrometer 1.15.0, and optional JDBC Hikari integration 6.2.1. Certification
+uses Temurin 25.0.4.1+1-LTS with Java 17 fixture bytecode and H2 2.3.232.
+Support covers this setup and the fixed contract, not arbitrary Micrometer
+exposition. Quarkus and Micronaut retain separate label and health contracts.
+
+The adapter normalizes request count, 404/4xx/5xx counts, accumulated duration,
+process CPU, JVM heap used, process start, and uptime. HTTP timers are lazy at
+idle and restart, and include management self-traffic once requests complete.
+Missing timers remain unavailable. Source routes, exceptions, datasource names,
+and other labels are not stored as dimensions. Invalid concepts produce focused
+partial warnings while independent valid metrics remain usable.
+
+Management health is independent and optional. A conventional `/prometheus`
+path derives sibling `/health`; custom paths can use `health_url`. Database
+health requires visible `details.jdbc.status`, and remains unavailable when
+JDBC details are absent or hidden. UP/DOWN aggregate application health is
+accepted over HTTP 200 or 503. Metrics success does not synthesize stored health.
+As with Quarkus, successful metrics without explicit health can appear as
+operational `UP` on the dashboard. Initial derived-health absence is cached until
+a detectable process restart or collector recreation; known failures warn and
+are retried. Disabling health can leave metrics usable; removing management
+from the certified dependency graph removes both endpoints.
+
+Use `statlite inspect --type micronaut` with a base URL or exact endpoint.
+Inspection checks the same metrics contract as collection and does not probe
+health. It does not prove Micronaut identity, and untyped inspection does not
+attempt Micronaut recognition. See [configuration and setup](configuration.md#micronaut-micrometer-metrics)
+for dependencies, endpoint resolution, health visibility, and overrides.
+
## Scope boundaries
For applications without a first-class framework target, use the [StatLite
diff --git a/docs/monitoring-options.md b/docs/monitoring-options.md
index 3ec0be1..8d7ee20 100644
--- a/docs/monitoring-options.md
+++ b/docs/monitoring-options.md
@@ -9,8 +9,8 @@ applications, local SQLite history, retention, host visibility, and a dashboard
in one small process. Current measurements show roughly 10 to 15 MiB of idle
RSS. This is an observed range, not a maximum-memory guarantee.
-Beyond its Spring Boot and Quarkus integrations, applications can expose the
-small, fixed [StatLite Metrics v1 profile](statlite-metrics-v1.md) to use the
+Beyond its Spring Boot, Quarkus, and Micronaut integrations, applications can
+expose the small, fixed [StatLite Metrics v1 profile](statlite-metrics-v1.md) to use the
same collection, history, and dashboard without adopting a general-purpose
telemetry pipeline.
diff --git a/docs/product.md b/docs/product.md
index bc10e7a..6d534b4 100644
--- a/docs/product.md
+++ b/docs/product.md
@@ -83,7 +83,7 @@ value is clear; any future target still requires a separate product decision.
### Currently supported targets
-StatLite has three supported target types:
+StatLite has four supported target types:
* `spring`: Spring Boot Actuator and a fixed set of Micrometer metrics. This
is the default target type when `type` is omitted.
@@ -94,8 +94,12 @@ StatLite has three supported target types:
* `quarkus`: Quarkus Micrometer Prometheus/OpenMetrics metrics at an exact
configured exposition endpoint. It normalizes a fixed request, latency,
process, heap, and restart vocabulary.
-Spring, Quarkus, and StatLite Metrics are application integrations. None of
-these boundaries is an arbitrary metrics API.
+* `micronaut`: the certified Micronaut Micrometer setup at an exact
+ exposition endpoint, with independent optional management health and visible
+ JDBC aggregate health. It uses the existing normalized concepts.
+
+Spring, Quarkus, Micronaut, and StatLite Metrics are application integrations.
+None of these boundaries is an arbitrary metrics API.
### Framework-first integration model
@@ -153,8 +157,8 @@ metrics reachability. A successful metrics scrape establishes that the target
is reporting to StatLite, but is not equivalent to framework aggregate health.
Spring Boot
Actuator health is normally an established part of the `spring` integration;
-SmallRye Health for Quarkus and health endpoints for any future framework
-target remain optional capabilities. If Spring health retrieval
+SmallRye Health for Quarkus, Micronaut management health, and health endpoints
+for any future framework target remain optional capabilities. If Spring health retrieval
fails, StatLite retains independently usable metrics, leaves health
unavailable, and records a focused warning. A poll without any usable metric
sample remains a collection failure even when health responded.
@@ -195,7 +199,7 @@ non-healthy states use warning or error styling.
## Deployment topology
For a collocated deployment, configure application targets (`spring`, `quarkus`,
-or `statlite-metrics`) for application and process data, and `statlite-self`
+`micronaut`, or `statlite-metrics`) for application and process data, and `statlite-self`
through `/statlite/metrics` to monitor StatLite itself. The self response also
provides CPU and memory for the host or execution environment visible to
StatLite, plus capacity for the filesystem containing its SQLite database, so
diff --git a/examples/README.md b/examples/README.md
index 88b30bd..838468e 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -11,6 +11,7 @@ FastAPI, Express, Django, Go `net/http`, and Gin application setup.
| File | Purpose |
|------|---------|
| `actuator.yaml` | Single Spring Boot Actuator target with Basic Auth placeholders |
+| [micronaut.yaml](micronaut.yaml) | Exact Micronaut Prometheus endpoint; optional management health |
| `statlite.yaml` | Monitor another StatLite instance via `statlite-metrics` |
| `multi-target.yaml` | Mixed targets: Actuator, StatLite Metrics, and self-monitoring |
diff --git a/examples/micronaut.yaml b/examples/micronaut.yaml
new file mode 100644
index 0000000..1b7df4a
--- /dev/null
+++ b/examples/micronaut.yaml
@@ -0,0 +1,13 @@
+# Requires the Micronaut setup documented in docs/configuration.md.
+server:
+ listen: "127.0.0.1:9090"
+storage:
+ sqlite_path: "./statlite.sqlite"
+polling:
+ interval: "30s"
+targets:
+ - name: "orders"
+ type: "micronaut"
+ url: "http://localhost:8080/prometheus"
+ # For a custom metrics path, set the exact optional health_url.
+ # health_url: "http://localhost:8080/health"
From c387270d3aee80e0183e1f5903380a9441940a73 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 14:01:02 -0700
Subject: [PATCH 05/13] Add public Micronaut demo and deterministic CI journey
(#56)
---
.../workflows/integration-certification.yml | 22 +-
docs/configuration.md | 3 +
docs/integration-testing.md | 10 +-
examples/README.md | 1 +
examples/micronaut-metrics-demo/.gitignore | 2 +
examples/micronaut-metrics-demo/README.md | 51 +++++
examples/micronaut-metrics-demo/pom.xml | 66 ++++++
.../src/main/java/example/Application.java | 9 +
.../main/java/example/ProbeController.java | 17 ++
.../src/main/resources/application.properties | 6 +
.../src/main/resources/logback.xml | 6 +
examples/micronaut-metrics-demo/statlite.yaml | 11 +
examples/micronaut-metrics-demo/traffic.sh | 11 +
internal/inspect/micronaut_test.go | 5 +-
scripts/ci/integration-micronaut.sh | 196 ++++++++++++++++++
15 files changed, 411 insertions(+), 5 deletions(-)
create mode 100644 examples/micronaut-metrics-demo/.gitignore
create mode 100644 examples/micronaut-metrics-demo/README.md
create mode 100644 examples/micronaut-metrics-demo/pom.xml
create mode 100644 examples/micronaut-metrics-demo/src/main/java/example/Application.java
create mode 100644 examples/micronaut-metrics-demo/src/main/java/example/ProbeController.java
create mode 100644 examples/micronaut-metrics-demo/src/main/resources/application.properties
create mode 100644 examples/micronaut-metrics-demo/src/main/resources/logback.xml
create mode 100644 examples/micronaut-metrics-demo/statlite.yaml
create mode 100755 examples/micronaut-metrics-demo/traffic.sh
create mode 100755 scripts/ci/integration-micronaut.sh
diff --git a/.github/workflows/integration-certification.yml b/.github/workflows/integration-certification.yml
index 0e052bd..eff91a9 100644
--- a/.github/workflows/integration-certification.yml
+++ b/.github/workflows/integration-certification.yml
@@ -32,6 +32,7 @@ jobs:
- case: gin
app_port: 8080
- case: spring
+ - case: micronaut
- case: quarkus
steps:
- name: Checkout
@@ -74,7 +75,7 @@ jobs:
cache-dependency-path: examples/node-express-demo/package-lock.json
- name: Set up Java
- if: matrix.case == 'spring' || matrix.case == 'quarkus'
+ if: matrix.case == 'spring' || matrix.case == 'quarkus' || matrix.case == 'micronaut'
uses: actions/setup-java@v4
with:
distribution: temurin
@@ -123,6 +124,19 @@ jobs:
working-directory: examples/quarkus-metrics-demo
run: mvn --batch-mode --no-transfer-progress package
+ - name: Build Micronaut demo
+ if: matrix.case == 'micronaut'
+ working-directory: examples/micronaut-metrics-demo
+ run: mvn --batch-mode --no-transfer-progress package dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
+
+ - name: Report Micronaut versions
+ if: matrix.case == 'micronaut'
+ working-directory: examples/micronaut-metrics-demo
+ run: |
+ java --version
+ mvn --version
+ mvn --batch-mode --no-transfer-progress dependency:tree '-Dincludes=io.micronaut:micronaut-core,io.micronaut:micronaut-management,io.micronaut.micrometer:*,io.micrometer:*'
+
- name: Report Spring Boot versions
if: matrix.case == 'spring'
working-directory: examples/spring-actuator-demo
@@ -161,3 +175,9 @@ jobs:
env:
STATLITE_BIN: ${{ runner.temp }}/statlite
run: ./scripts/ci/integration-quarkus.sh
+
+ - name: Run Micronaut integration journey
+ if: matrix.case == 'micronaut'
+ env:
+ STATLITE_BIN: ${{ runner.temp }}/statlite
+ run: ./scripts/ci/integration-micronaut.sh
diff --git a/docs/configuration.md b/docs/configuration.md
index 1b386a1..e75bd0c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -330,6 +330,9 @@ incompatible.
### Micronaut Micrometer metrics
+Run the [Micronaut demo](../examples/micronaut-metrics-demo/) for a complete
+application, management configuration, and deterministic traffic recipe.
+
```yaml
targets:
- name: "orders"
diff --git a/docs/integration-testing.md b/docs/integration-testing.md
index 1cc4853..60e945d 100644
--- a/docs/integration-testing.md
+++ b/docs/integration-testing.md
@@ -30,7 +30,10 @@ not increase application counters. The Go `net/http` and Gin cases establish
a baseline, then assert exact two-poll counter deltas and derived average
latency, including Gin's recovered pre-commit panic. The Spring Boot and
Quarkus cases check the framework-specific health or process/runtime signals
-that their examples publish.
+that their examples publish. The Micronaut case checks typed inspection, exact
+404/4xx/5xx deltas between successful stored polls, accumulated duration, runtime
+signals, aggregate UP health, and unavailable database health. Management
+self-traffic contributes to its total, so the total increase is a lower bound.
## Integrations in the public checks
@@ -51,6 +54,9 @@ The shared matrix currently exercises:
- [Quarkus](../examples/quarkus-metrics-demo/) through the Micrometer metrics
integration.
+- [Micronaut](../examples/micronaut-metrics-demo/) through the
+ [Micrometer metrics integration](configuration.md#micronaut-micrometer-metrics).
+
Inspect the implementation and current triggers in the public
[`integration certification` workflow](https://github.com/PVRLabs/statlite/actions/workflows/integration-certification.yml).
It runs for every pull request, every push to `main`, and weekly on Monday at
@@ -58,7 +64,7 @@ It runs for every pull request, every push to `main`, and weekly on Monday at
not hide the results for the others.
The release workflow reuses this same workflow as a prerequisite before it
-creates a release tag, so a release cannot proceed until all seven public cases
+creates a release tag, so a release cannot proceed until all eight public cases
pass on the dispatched commit.
These are focused public integration checks for the documented monitoring
diff --git a/examples/README.md b/examples/README.md
index 838468e..f3782e8 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -26,6 +26,7 @@ go run ./cmd/statlite --config examples/actuator.yaml
| Directory | What it shows |
|-----------|---------------|
| [spring-actuator-demo](spring-actuator-demo/) | Runnable Spring Boot app with Actuator and Micrometer metrics, traffic generator, and dashboard preview |
+| [micronaut-metrics-demo](micronaut-metrics-demo/) | Pinned Micronaut 4.9.9 application with Prometheus metrics, ordinary management health, and deterministic traffic |
| [quarkus-metrics-demo](quarkus-metrics-demo/) | Pinned Quarkus 3.39.1 Micrometer metrics fixture, contract captures, and traffic recipe |
| [python-fastapi-demo](python-fastapi-demo/) | Runnable companion to the canonical [FastAPI guide](../docs/integrate/python/fastapi.md), with middleware and framework-level tests |
| [node-express-demo](node-express-demo/) | Runnable companion to the canonical [Express guide](../docs/integrate/node/express.md), with middleware and framework-level tests |
diff --git a/examples/micronaut-metrics-demo/.gitignore b/examples/micronaut-metrics-demo/.gitignore
new file mode 100644
index 0000000..e1ffec2
--- /dev/null
+++ b/examples/micronaut-metrics-demo/.gitignore
@@ -0,0 +1,2 @@
+/target/
+/*.log
diff --git a/examples/micronaut-metrics-demo/README.md b/examples/micronaut-metrics-demo/README.md
new file mode 100644
index 0000000..36196f6
--- /dev/null
+++ b/examples/micronaut-metrics-demo/README.md
@@ -0,0 +1,51 @@
+# Micronaut Micrometer demo
+
+A self-contained public example for StatLite's explicit `micronaut` target.
+Platform parent 4.9.2 pins Micronaut 4.9.9, Micronaut Micrometer 5.12.0,
+and Micrometer 1.15.0. Sources compile to Java 17 bytecode. Public CI builds
+and runs with Eclipse Temurin Java 21 LTS; private certification uses its
+separately pinned Java 25 runtime.
+
+From this directory, with Java 21 and Maven installed:
+
+```sh
+mvn --batch-mode --no-transfer-progress package dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
+java -cp "target/classes:$(cat target/classpath.txt)" example.Application
+```
+
+The application binds to loopback port 18084. Management exposes ordinary
+`/prometheus` metrics and `/health` aggregate UP health. No datasource is
+configured, so database health is unavailable. HTTP timers appear after traffic.
+In another terminal, run `./traffic.sh`: exactly one request each to
+`/probe/ok` (200), `/probe/missing` (404), `/probe/bad` (400), and
+`/probe/error` (500). The missing route exercises the framework's normal 404.
+Management requests also contribute to HTTP request counts and duration.
+
+From the repository root, use the example configuration and typed inspection:
+
+```sh
+go run ./cmd/statlite --config examples/micronaut-metrics-demo/statlite.yaml
+go run ./cmd/statlite inspect --type micronaut http://127.0.0.1:18084
+```
+
+Typed inspection checks the supported metrics contract and resolves the base
+URL to `/prometheus`. Compatibility does not establish framework identity.
+See [configuration](../../docs/configuration.md#micronaut-micrometer-metrics)
+for normalization and optional health behavior.
+
+To run the CI journey, stop the manually started application first, then from
+the repository root:
+
+```sh
+go build -o /tmp/statlite-micronaut ./cmd/statlite
+STATLITE_BIN=/tmp/statlite-micronaut ./scripts/ci/integration-micronaut.sh
+```
+
+The journey uses loopback ports 18084 and 19094, temporary SQLite storage,
+an hourly polling interval with explicit debug polls, and bounded traffic.
+It waits for startup collections and their possible follow-up to settle before
+beginning the controlled baseline/traffic sequence.
+It checks inspection, stored counter deltas, request duration, runtime signals,
+application UP health, and absent database health, then stops both processes.
+[Public integration testing](../../docs/integration-testing.md) describes
+the shared CI and release prerequisite.
diff --git a/examples/micronaut-metrics-demo/pom.xml b/examples/micronaut-metrics-demo/pom.xml
new file mode 100644
index 0000000..24c5716
--- /dev/null
+++ b/examples/micronaut-metrics-demo/pom.xml
@@ -0,0 +1,66 @@
+
+
+ 4.0.0
+
+ io.micronaut.platform
+ micronaut-parent
+ 4.9.2
+
+ example
+ micronaut-metrics-demo
+ 1.0.0
+
+ 17
+ netty
+ example.Application
+
+
+
+ io.micronaut
+ micronaut-management
+
+
+ io.micronaut
+ micronaut-http-server-netty
+
+
+ io.micronaut
+ micronaut-jackson-databind
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-core
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-registry-prometheus
+
+
+ ch.qos.logback
+ logback-classic
+ runtime
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.10.0
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ io.micronaut
+ micronaut-inject-java
+ ${micronaut.core.version}
+
+
+
+
+
+
+
diff --git a/examples/micronaut-metrics-demo/src/main/java/example/Application.java b/examples/micronaut-metrics-demo/src/main/java/example/Application.java
new file mode 100644
index 0000000..a469c82
--- /dev/null
+++ b/examples/micronaut-metrics-demo/src/main/java/example/Application.java
@@ -0,0 +1,9 @@
+package example;
+
+import io.micronaut.runtime.Micronaut;
+
+public class Application {
+ public static void main(String[] args) {
+ Micronaut.run(Application.class, args);
+ }
+}
diff --git a/examples/micronaut-metrics-demo/src/main/java/example/ProbeController.java b/examples/micronaut-metrics-demo/src/main/java/example/ProbeController.java
new file mode 100644
index 0000000..0ad9fa0
--- /dev/null
+++ b/examples/micronaut-metrics-demo/src/main/java/example/ProbeController.java
@@ -0,0 +1,17 @@
+package example;
+
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+
+@Controller("/probe")
+public class ProbeController {
+ @Get("/ok")
+ public String ok() { return "ok"; }
+
+ @Get("/bad")
+ public HttpResponse bad() { return HttpResponse.badRequest("bad"); }
+
+ @Get("/error")
+ public HttpResponse error() { return HttpResponse.serverError("error"); }
+}
diff --git a/examples/micronaut-metrics-demo/src/main/resources/application.properties b/examples/micronaut-metrics-demo/src/main/resources/application.properties
new file mode 100644
index 0000000..bfecdce
--- /dev/null
+++ b/examples/micronaut-metrics-demo/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+micronaut.application.name=micronaut-metrics-demo
+micronaut.server.host=127.0.0.1
+micronaut.server.port=18084
+micronaut.metrics.enabled=true
+endpoints.prometheus.sensitive=false
+endpoints.health.sensitive=false
diff --git a/examples/micronaut-metrics-demo/src/main/resources/logback.xml b/examples/micronaut-metrics-demo/src/main/resources/logback.xml
new file mode 100644
index 0000000..3c897ba
--- /dev/null
+++ b/examples/micronaut-metrics-demo/src/main/resources/logback.xml
@@ -0,0 +1,6 @@
+
+
+ %d{HH:mm:ss} %-5level %logger{36} - %msg%n
+
+
+
diff --git a/examples/micronaut-metrics-demo/statlite.yaml b/examples/micronaut-metrics-demo/statlite.yaml
new file mode 100644
index 0000000..5011a3c
--- /dev/null
+++ b/examples/micronaut-metrics-demo/statlite.yaml
@@ -0,0 +1,11 @@
+server:
+ listen: "127.0.0.1:8090"
+storage:
+ sqlite_path: "micronaut-demo.sqlite"
+polling:
+ interval: "30s"
+ timeout: "5s"
+targets:
+ - name: micronaut-metrics-demo
+ type: micronaut
+ url: http://127.0.0.1:18084/prometheus
diff --git a/examples/micronaut-metrics-demo/traffic.sh b/examples/micronaut-metrics-demo/traffic.sh
new file mode 100755
index 0000000..665d15a
--- /dev/null
+++ b/examples/micronaut-metrics-demo/traffic.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+base=${BASE_URL:-http://127.0.0.1:18084}
+expect_status() {
+ actual=$(curl --noproxy '*' --max-time 5 -sS -o /dev/null -w '%{http_code}' "$base$2")
+ [ "$actual" = "$1" ] || { printf 'expected %s for %s, got %s\n' "$1" "$2" "$actual" >&2; exit 1; }
+}
+expect_status 200 /probe/ok
+expect_status 404 /probe/missing
+expect_status 400 /probe/bad
+expect_status 500 /probe/error
diff --git a/internal/inspect/micronaut_test.go b/internal/inspect/micronaut_test.go
index f476c14..aa6ba43 100644
--- a/internal/inspect/micronaut_test.go
+++ b/internal/inspect/micronaut_test.go
@@ -3,8 +3,6 @@ package inspect
import (
"context"
"fmt"
- "github.com/pvrlabs/statlite/internal/collector"
- "github.com/pvrlabs/statlite/internal/prometheus"
"io"
"net/http"
"net/http/httptest"
@@ -12,6 +10,9 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/pvrlabs/statlite/internal/collector"
+ "github.com/pvrlabs/statlite/internal/prometheus"
)
func TestTypedMicronautInspectionUsesExactEndpointAndOneBoundedScrape(t *testing.T) {
diff --git a/scripts/ci/integration-micronaut.sh b/scripts/ci/integration-micronaut.sh
new file mode 100755
index 0000000..e0a8682
--- /dev/null
+++ b/scripts/ci/integration-micronaut.sh
@@ -0,0 +1,196 @@
+#!/bin/sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH=; cd -- "$(dirname -- "$0")" && pwd)
+REPO_DIR=$(CDPATH=; cd -- "$SCRIPT_DIR/../.." && pwd)
+MICRONAUT_DIR="$REPO_DIR/examples/micronaut-metrics-demo"
+STATLITE_BIN=${STATLITE_BIN:-"$REPO_DIR/statlite"}
+
+WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/statlite-micronaut.XXXXXX")
+APP_LOG="$WORK_DIR/micronaut.log"
+STATLITE_LOG="$WORK_DIR/statlite.log"
+STATLITE_CONFIG="$WORK_DIR/statlite.yaml"
+APP_PID=
+STATLITE_PID=
+
+cleanup() {
+ status=${1:-$?}
+ trap - EXIT HUP INT TERM
+ set +e
+ stop_process() {
+ name=$1
+ pid=$2
+ [ -n "$pid" ] || return
+ if kill -0 "$pid" 2>/dev/null; then
+ kill "$pid" 2>/dev/null || true
+ i=0
+ while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 25 ]; do
+ sleep 0.2
+ i=$((i + 1))
+ done
+ if kill -0 "$pid" 2>/dev/null; then
+ printf 'forcing %s process %s to exit\n' "$name" "$pid" >&2
+ kill -KILL "$pid" 2>/dev/null || true
+ fi
+ fi
+ wait "$pid" 2>/dev/null || true
+ }
+ stop_process StatLite "$STATLITE_PID"
+ stop_process Micronaut "$APP_PID"
+ for owned_port in ${APP_PID:+18084} ${STATLITE_PID:+19094}; do
+ if lsof -nP -iTCP:"$owned_port" -sTCP:LISTEN >/dev/null 2>&1; then
+ printf 'listener remains on port %s after cleanup\n' "$owned_port" >&2
+ status=1
+ fi
+ done
+ if [ "$status" -ne 0 ]; then
+ printf '%s\n' '--- Micronaut application log ---' >&2
+ tail -100 "$APP_LOG" >&2 || true
+ printf '%s\n' '--- StatLite log ---' >&2
+ tail -100 "$STATLITE_LOG" >&2 || true
+ fi
+ rm -rf "$WORK_DIR"
+ exit "$status"
+}
+
+trap 'cleanup 129' HUP
+trap 'cleanup 130' INT
+trap 'cleanup 143' TERM
+trap cleanup EXIT
+
+fail() {
+ printf 'Micronaut integration failed: %s\n' "$*" >&2
+ exit 1
+}
+
+[ -x "$STATLITE_BIN" ] || fail "StatLite binary is not executable: $STATLITE_BIN"
+
+# Dedicated loopback ports; each workflow case runs in its own runner.
+APP_URL=http://127.0.0.1:18084
+METRICS_URL="$APP_URL/prometheus"
+STATLITE_URL=http://127.0.0.1:19094
+
+# Fail before starting if a listener would make readiness ambiguous.
+for port in 18084 19094; do
+ if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
+ fail "port $port is already in use"
+ fi
+done
+classpath=$(cat "$MICRONAUT_DIR/target/classpath.txt")
+java -cp "$MICRONAUT_DIR/target/classes:$classpath" example.Application >"$APP_LOG" 2>&1 &
+APP_PID=$!
+wait_for_url() {
+ name=$1
+ url=$2
+ pid=$3
+ i=0
+ while [ "$i" -lt 60 ]; do
+ if curl --noproxy '*' --max-time 2 -fsS "$url" >/dev/null 2>&1; then
+ return 0
+ fi
+ if ! kill -0 "$pid" 2>/dev/null; then
+ fail "$name exited before becoming ready"
+ fi
+ i=$((i + 1))
+ sleep 1
+ done
+ fail "$name did not become ready at $url"
+}
+
+wait_for_url 'Micronaut demo' "$APP_URL/health" "$APP_PID"
+curl --noproxy '*' --max-time 5 -fsS "$APP_URL/health" |
+ jq -e '.status == "UP"' >/dev/null
+
+"$STATLITE_BIN" inspect --type micronaut "$APP_URL" >"$WORK_DIR/inspect.txt" 2>&1
+grep -Fq 'type: micronaut' "$WORK_DIR/inspect.txt"
+grep -Fq "$METRICS_URL" "$WORK_DIR/inspect.txt"
+grep -Eq '^Compatibility: (compatible|partial)$' "$WORK_DIR/inspect.txt"
+
+cat >"$STATLITE_CONFIG" <"$STATLITE_LOG" 2>&1 &
+STATLITE_PID=$!
+wait_for_url StatLite "$STATLITE_URL/healthz" "$STATLITE_PID"
+
+# /healthz can be ready before the startup collection finishes. The first
+# counter baseline schedules a follow-up after 3s, even with a 1h interval.
+# Require a successful stored poll and 10s of unchanged IDs: this exceeds
+# the follow-up delay plus the configured 5s collection timeout.
+settled_id=0
+quiet_seconds=0
+attempt=0
+while [ "$quiet_seconds" -lt 10 ]; do
+ [ "$attempt" -lt 40 ] || fail "startup polls did not settle"
+ kill -0 "$STATLITE_PID" 2>/dev/null || fail "StatLite exited during startup settling"
+ curl --noproxy '*' --max-time 2 -fsS "$STATLITE_URL/api/summary?range=1h" >"$WORK_DIR/startup-summary.json"
+ current_id=$(jq -r '
+ if .latest.status == "ok" and .monitor.last_successful_stored_poll_id > 0 and
+ .latest.poll_id == .monitor.last_successful_stored_poll_id and
+ .monitor.last_stored_poll_id == .latest.poll_id
+ then .latest.poll_id else 0 end
+ ' "$WORK_DIR/startup-summary.json")
+ if [ "$current_id" -gt 0 ] && [ "$current_id" = "$settled_id" ]; then
+ quiet_seconds=$((quiet_seconds + 1))
+ else
+ settled_id=$current_id
+ quiet_seconds=0
+ fi
+ attempt=$((attempt + 1))
+ if [ "$quiet_seconds" -lt 10 ]; then
+ sleep 1
+ fi
+done
+printf 'Startup polls settled at stored poll %s.\n' "$settled_id"
+
+poll() {
+ curl --noproxy '*' --max-time 10 -fsS "$STATLITE_URL/debug/poll-now" >"$WORK_DIR/$1.json"
+ jq -e '.status == "ok" and .result.target_name == "micronaut-metrics-demo" and
+ .result.health_status == "UP" and
+ ((.result.db_health_status // "") == "")' "$WORK_DIR/$1.json" >/dev/null || fail "$1 collection failed"
+ curl --noproxy '*' --max-time 5 -fsS "$STATLITE_URL/api/summary?range=1h" >"$WORK_DIR/$1-summary.json"
+ jq -e '.selected_target.name == "micronaut-metrics-demo" and .latest.status == "ok" and
+ .monitor.last_successful_stored_poll_id > 0 and
+ .latest.poll_id == .monitor.last_successful_stored_poll_id' "$WORK_DIR/$1-summary.json" >/dev/null || fail "$1 poll was not stored"
+ jq -s -e '.[0].poll_id > 0 and .[0].poll_id == .[1].latest.poll_id and
+ .[0].result.poll_finished_at == .[1].latest.result.poll_finished_at' \
+ "$WORK_DIR/$1.json" "$WORK_DIR/$1-summary.json" >/dev/null || fail "$1 summary does not match the forced poll"
+}
+# Startup, its possible follow-up, and readiness traffic precede this baseline.
+# After settling, the 1h interval keeps regular polls outside this short journey.
+poll baseline
+BASE_URL="$APP_URL" "$MICRONAUT_DIR/traffic.sh"
+poll traffic
+
+jq -s -e '
+ def sample($p; $key): [$p.result.samples[] | select(.key == $key)][0].value;
+ def delta($key): sample(.[1]; $key) - sample(.[0]; $key);
+ delta("http_requests_total") >= 4 and
+ delta("http_404_total") == 1 and delta("http_4xx_total") == 2 and delta("http_5xx_total") == 1 and
+ delta("http_request_time_total_seconds") > 0 and
+ (sample(.[1]; "process_cpu_usage") | type == "number" and . >= 0 and . <= 1) and
+ sample(.[1]; "jvm_heap_used_bytes") > 0 and
+ sample(.[1]; "process_start_time") > 0 and sample(.[1]; "process_uptime") > 0 and
+ sample(.[1]; "process_start_time") == sample(.[0]; "process_start_time")
+' "$WORK_DIR/baseline.json" "$WORK_DIR/traffic.json" >/dev/null || fail "unexpected counter/runtime samples"
+# Management requests also enter HTTP timers; total is deliberately a lower
+# bound because a scrape excludes its own unfinished request.
+jq -s -e '.[1].latest.poll_id > .[0].latest.poll_id and
+ .[1].latest.poll_id == (.[0].latest.poll_id + 1)' \
+ "$WORK_DIR/baseline-summary.json" "$WORK_DIR/traffic-summary.json" >/dev/null || fail "unexpected stored poll boundary"
+curl --noproxy '*' --max-time 5 -fsS "$STATLITE_URL/api/series?range=1h" |
+ jq -e '(.points | length) >= 2 and .latest_point.requests >= 4 and
+ .latest_point.http_404 == 1 and .latest_point.http_4xx == 2 and
+ .latest_point.http_5xx == 1 and .latest_point.average_latency_seconds > 0' >/dev/null ||
+ fail "stored series did not expose the traffic deltas"
+printf '%s\n' 'Micronaut integration passed.'
From 851083bf4663e2717a3fe89d827fc027578ff295 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 14:32:25 -0700
Subject: [PATCH 06/13] Clarify Metrics v1 producer identity and endpoint
contract
---
docs/statlite-metrics-v1.md | 25 ++++++++++++++++++++-----
internal/server/metrics.go | 2 ++
internal/server/server_test.go | 4 ++--
3 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/docs/statlite-metrics-v1.md b/docs/statlite-metrics-v1.md
index 91c411d..23622bb 100644
--- a/docs/statlite-metrics-v1.md
+++ b/docs/statlite-metrics-v1.md
@@ -15,6 +15,17 @@ to serve metrics. StatLite exposes this canonical profile at
`/statlite/metrics`; its `/healthz` endpoint is readiness-only and is not a
profile endpoint.
+A non-2xx metrics response is a collection failure; consumers should not treat
+its body as a valid metrics snapshot.
+
+The endpoint exposes operational data and should normally be kept on loopback,
+a private network, or behind appropriate network/proxy controls. The built-in
+`statlite-metrics` target currently does not send authentication credentials.
+
+The endpoint should return a current snapshot rather than a cached response.
+If it is routed through a reverse proxy, configure that path so intermediary
+caching does not serve stale metrics.
+
A complete response looks like this:
```json
@@ -85,7 +96,7 @@ copyable implementations, runnable examples, and setup instructions.
| Field | Type | Unit | Optional | Semantics |
|---|---|---|---|---|
| `schema` | string | N/A | No | Must be `statlite-metrics/v1`. |
-| `integration` | string | N/A | Yes | Non-empty identifier for the documented producer implementation family, such as `express`, `django`, or `fastapi`. It is informational provenance for troubleshooting, not runtime detection or an authenticated assertion. StatLite currently ignores it and it does not affect collection semantics. |
+| `integration` | string | N/A | Yes | Non-empty identifier for the documented producer implementation family, such as `express`, `django`, `fastapi`, or `statlite-self-monitoring`. It is informational provenance for troubleshooting, not runtime detection or an authenticated assertion. StatLite currently ignores it and it does not affect collection semantics. |
| `status` | string | N/A | No | Non-empty application health/status text. |
| `database_status` | string | N/A | Yes | Non-empty status text for an application database dependency when the producer can safely determine it. StatLite self-monitoring emits `UP` or `DOWN` from a cached SQLite `PingContext` check, refreshed on startup and every 60 seconds; a closed local store reports `DOWN` immediately. |
| `started_at` | string | RFC 3339 timestamp | Yes | Process start time; recommended for restart detection. |
@@ -167,10 +178,14 @@ Self-monitoring does not supply measurements for a remote application host.
The configured StatLite target name is authoritative. The application should
not provide `target_name`, polling timestamps, or other StatLite-owned metadata.
-Producers copied from a documented framework integration should emit that
-implementation family's stable, lowercase `integration` identifier. Custom
-producers may omit it. Consumers must treat it as untrusted informational
-metadata rather than proof of the producer's framework or runtime.
+When emitting `integration`, use a stable lowercase identifier, preferably
+kebab-case. Producers copied from a documented framework integration should
+emit that implementation family's identifier. Custom producers may choose
+their own identifier or omit it. StatLite's built-in
+`/statlite/metrics` endpoint emits `integration: "statlite-self-monitoring"` to
+distinguish its producer implementation from application-owned integrations.
+Consumers must treat it as untrusted informational metadata rather than proof
+of the producer's framework or runtime.
Unknown fields are ignored for forward compatibility. Invalid optional fields
are skipped and reported as warnings without discarding otherwise valid metrics.
diff --git a/internal/server/metrics.go b/internal/server/metrics.go
index ca4cec6..192b9bd 100644
--- a/internal/server/metrics.go
+++ b/internal/server/metrics.go
@@ -12,6 +12,7 @@ import (
type statliteMetricsResponse struct {
Schema string `json:"schema"`
+ Integration string `json:"integration"`
Status string `json:"status"`
DatabaseStatus *string `json:"database_status,omitempty"`
StartedAt time.Time `json:"started_at"`
@@ -45,6 +46,7 @@ func (s *Server) handleStatliteMetrics(w http.ResponseWriter, _ *http.Request) {
response := statliteMetricsResponse{
Schema: collector.StatliteMetricsV1Schema,
+ Integration: "statlite-self-monitoring",
Status: "UP",
DatabaseStatus: s.databaseStatus(),
StartedAt: s.startedAt,
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 474ceae..860d095 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -552,8 +552,8 @@ func TestStatliteMetricsEmitsCanonicalProfileAndExcludesScrape(t *testing.T) {
if err := json.NewDecoder(first.Body).Decode(&response); err != nil {
t.Fatalf("decode metrics: %v", err)
}
- if response.Schema != collector.StatliteMetricsV1Schema || response.Status == "" || response.StartedAt.IsZero() {
- t.Fatalf("profile identity = %#v, want schema/status/started_at", response)
+ if response.Schema != collector.StatliteMetricsV1Schema || response.Integration != "statlite-self-monitoring" || response.Status == "" || response.StartedAt.IsZero() {
+ t.Fatalf("profile identity = %#v, want schema/integration/status/started_at", response)
}
if response.Metrics.RequestsTotal != 1 {
t.Fatalf("requests_total = %d, want 1", response.Metrics.RequestsTotal)
From 75566195be3e4901d27cff045985520f22548486 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 15:37:38 -0700
Subject: [PATCH 07/13] Document primary Micronaut 5 certification and retained
4 support (#56)
---
docs/configuration.md | 23 ++++++++++++++---------
examples/micronaut-metrics-demo/README.md | 3 ++-
2 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index e75bd0c..535bc24 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -346,9 +346,11 @@ Spring-only `actuator_base_url`, `metrics_source`, and `collect_host_metrics`
are rejected for Micronaut, including explicit empty/false values. Host metrics
are not inferred from application metrics.
-The certified dependency graph uses platform parent 4.9.2, Micronaut 4.9.9,
-Micronaut Micrometer 5.12.0, and Micrometer 1.15.0. In an application using this
-platform, include these dependencies:
+The primary tested graph uses platform parent 5.2.1, Micronaut core 5.2.11,
+Micronaut Micrometer 6.1.0, and Micrometer 1.17.1. The same adapter also passes
+the retained 4.9.9 regression (parent 4.9.2, Micronaut Micrometer 5.12.0,
+Micrometer 1.15.0), which the public Java 21 demo exercises. In an application
+using either platform, include these dependencies:
```xml
@@ -373,8 +375,8 @@ micronaut.metrics.enabled=true
endpoints.prometheus.sensitive=false
```
-Certification runs with Eclipse Temurin 25.0.4.1+1-LTS and Java 17 fixture
-bytecode. These are tested versions, not a claim that every runtime or
+Certification runs with Eclipse Temurin 25.0.4.1+1-LTS. The 5.x fixture uses
+Java 25 bytecode; the 4.9.9 regression fixture uses Java 17 bytecode. These are tested versions, not a claim that every runtime or
Micrometer setup is compatible. No Prometheus server or Grafana is required.
Removing management from this graph removes both `/prometheus` and `/health`.
To retain metrics with absent health, keep management installed and set
@@ -407,8 +409,10 @@ insufficient. Runtime-only idle exposition is compatible.
HTTP timers are absent before the first completed request at startup and
restart, so HTTP samples remain unavailable until meters exist. Once present,
counters include application and management requests, including scrapes,
-health requests, and inspection probes. A scrape does not include its own
-unfinished request. StatLite stores raw cumulative counters and derives
+health requests, and inspection probes. Scrape timing differs: the tested 4.9.9
+setup excludes its own unfinished request, while 5.x can record the scrape timer
+before generating the response body. The first scrape can therefore already
+contain real HTTP samples. StatLite stores raw cumulative counters and derives
nonnegative deltas at query time; process-start changes retain the existing
restart boundary semantics. Routes, exceptions, arbitrary labels, histogram
buckets, and percentiles are not stored.
@@ -439,8 +443,9 @@ and warn without discarding good metrics. Database health requires visible
`details.jdbc.status` with UP/DOWN. Absent or hidden details leave DB health
unavailable; invalid optional JDBC details warn while preserving valid app
health. Nested datasource details are ignored, and overall app health never
-fills in DB health. The certified JDBC setup uses `micronaut-jdbc-hikari` 6.2.1
-and H2 2.3.232. Exposing JDBC details to anonymous clients requires:
+fills in DB health. The primary 5.x JDBC setup uses `micronaut-jdbc-hikari` 7.2.0
+and H2 2.5.250; the retained 4.9.9 regression uses 6.2.1 and H2 2.3.232.
+Exposing JDBC details to anonymous clients requires:
```properties
endpoints.health.details-visible=ANONYMOUS
diff --git a/examples/micronaut-metrics-demo/README.md b/examples/micronaut-metrics-demo/README.md
index 36196f6..87a937a 100644
--- a/examples/micronaut-metrics-demo/README.md
+++ b/examples/micronaut-metrics-demo/README.md
@@ -4,7 +4,8 @@ A self-contained public example for StatLite's explicit `micronaut` target.
Platform parent 4.9.2 pins Micronaut 4.9.9, Micronaut Micrometer 5.12.0,
and Micrometer 1.15.0. Sources compile to Java 17 bytecode. Public CI builds
and runs with Eclipse Temurin Java 21 LTS; private certification uses its
-separately pinned Java 25 runtime.
+separately pinned Java 25 runtime for the default 5.x certification and the
+retained 4.9.9 regression. This public example keeps the 4.9.9 / Java 21 setup.
From this directory, with Java 21 and Maven installed:
From b59cf3c7939bea21c58deb09228152d025fd703e Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 18:32:28 -0700
Subject: [PATCH 08/13] docs: add canonical framework target references
---
README.md | 14 +-
docs/configuration.md | 218 ++--------------------
docs/integration-testing.md | 10 +-
docs/integrations.md | 96 +++-------
docs/targets/micronaut.md | 186 ++++++++++++++++++
docs/targets/quarkus.md | 109 +++++++++++
docs/targets/spring.md | 91 +++++++++
examples/micronaut-metrics-demo/README.md | 2 +-
examples/micronaut.yaml | 2 +-
examples/quarkus-metrics-demo/README.md | 28 +--
examples/spring-actuator-demo/README.md | 3 +
11 files changed, 450 insertions(+), 309 deletions(-)
create mode 100644 docs/targets/micronaut.md
create mode 100644 docs/targets/quarkus.md
create mode 100644 docs/targets/spring.md
diff --git a/README.md b/README.md
index 85f8eaf..97c26af 100644
--- a/README.md
+++ b/README.md
@@ -139,19 +139,19 @@ When a target has no health signal, the dashboard reports whether StatLite is
successfully receiving its metrics without treating reachability as application
health.
-- **Spring Boot:** Collects authoritative health when Actuator health is
- available and automatically selects a compatible Micrometer Prometheus
+- **[Spring Boot](docs/targets/spring.md):** Collects authoritative health
+ when Actuator health is available and automatically selects a compatible Micrometer Prometheus
endpoint or Actuator JSON for request, JVM, process, and optional host
metrics. Independently usable metrics remain reportable if health retrieval
fails.
-- **Quarkus Micrometer:** Collects bounded request, latency, CPU, heap, process,
- and restart concepts from an exact Prometheus/OpenMetrics endpoint. SmallRye
+- **[Quarkus Micrometer](docs/targets/quarkus.md):** Collects bounded request,
+ latency, CPU, heap, process, and restart concepts from an exact Prometheus/OpenMetrics endpoint. SmallRye
Health is an optional capability when the application publishes it.
-- **Micronaut Micrometer:** Collects the existing request, duration, CPU, heap,
- process, and restart concepts from an exact configured Prometheus endpoint,
+- **[Micronaut Micrometer](docs/targets/micronaut.md):** Collects the existing request,
+ duration, CPU, heap, process, and restart concepts from an exact configured Prometheus endpoint,
conventionally `/prometheus`. Management health is optional; database health
requires visible JDBC aggregate status.
- See the [certified setup](docs/configuration.md#micronaut-micrometer-metrics).
+ See the [certified setup](docs/targets/micronaut.md).
- **[StatLite Metrics v1](docs/statlite-metrics-v1.md):** A small, fixed JSON
endpoint that applications in any language or framework can implement. See
the [direct integration guides](docs/integrate/) for FastAPI, Express,
diff --git a/docs/configuration.md b/docs/configuration.md
index 535bc24..0fbe911 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -71,11 +71,8 @@ statlite inspect --type quarkus 'http://localhost:9000'
statlite inspect --type quarkus 'http://localhost:9000/q/metrics'
```
-Typed Quarkus inspection accepts only StatLite's bounded Quarkus contract, not
-arbitrary Prometheus or Micrometer exposition. A root application URL resolves
-to `/q/metrics`. A non-root URL is tried first as an exact endpoint and then,
-after a conclusive miss, with `/q/metrics` appended. A URL containing a query
-string is always an exact endpoint and is preserved.
+See the [Quarkus target reference](targets/quarkus.md#target-inspection) for
+bounded compatibility and exact/custom endpoint resolution.
Micronaut requires explicit typed inspection:
@@ -85,18 +82,10 @@ statlite inspect --type micronaut 'http://localhost:8080/prometheus'
statlite inspect --type micronaut 'http://localhost:8080/service'
```
-A root base URL resolves to `/prometheus`. A non-root path is tried exactly;
-after HTTP 404/410 or parsed incompatible metrics, one context-path fallback
-appends `/prometheus`. Conventional `/prometheus` paths and URLs with a query
-(including a bare `?`) are exact and have no fallback. Escaped paths and queries
-are preserved. Authentication failures, malformed responses, timeouts, and other
-inconclusive failures stop resolution. Inspection has a five-second overall
-deadline and at most two scrapes, each with at most three same-origin redirects.
-It uses the runtime metrics evaluator, reports `partial` for compatible scrapes
-with warnings, and does not request health. Compatibility does not prove
-Micronaut identity. Untyped inspection adds no Micronaut probe or inference from
-arbitrary Micrometer exposition. Inspection has no new authentication options;
-configure authenticated targets manually with the shared Basic Auth block.
+See the [Micronaut target reference](targets/micronaut.md#target-inspection)
+for endpoint resolution, compatibility, and inspection limits. Inspection
+checks metrics and does not request health; configure authenticated targets
+manually with the shared Basic Auth block.
On success, plain `inspect` prints the recognized capabilities, a minimal
configuration, and commands to create or add it. The suggested target gets a
@@ -260,6 +249,9 @@ resulting CPU and disk values describe the execution environment visible to
the Spring Boot process, which may be a container rather than the physical
host.
+See the [Spring target reference](targets/spring.md) for source selection,
+health, remote host metrics, and the Spring demo.
+
### Quarkus Micrometer metrics
```yaml
@@ -269,70 +261,16 @@ targets:
url: "http://localhost:9000/q/metrics"
```
-For Quarkus, `url` is the conventional `/q/metrics` Prometheus/OpenMetrics
-endpoint, not a management base URL. StatLite derives the aggregate SmallRye
-Health endpoint by replacing `/q/metrics` with `/q/health` on the same origin
-and context path when the conventional capability is available. It performs
-one bounded health request and one bounded metrics scrape per polling cycle
-when health is configured or conventionally available, and uses the poll time
-rather than exposition timestamps. The pinned fixture includes Quarkus 3.39.1
-with Java 21 LTS, `quarkus-micrometer-registry-prometheus`, and the optional
-`quarkus-smallrye-health` extension.
-
-Health collection is best-effort and independent from metrics collection. If
-the derived `/q/health` endpoint is absent, aggregate framework health is
-unavailable. A successful metrics scrape is shown as `UP` on the dashboard,
-with its hint explaining that the label is based on collection rather than an
-explicit application-health assertion. Internally this remains reporting
-availability; StatLite does not synthesize or store application health `UP`.
-Database health remains unavailable without a datasource check. The absent
-capability is quiet and does not produce a recurring warning. A known or
-explicitly configured endpoint that returns an invalid or failed response may
-produce a focused warning without discarding valid metrics. Exact custom
-metrics paths remain supported; when the path is not a conventional
-`/q/metrics` path, StatLite does not infer a health endpoint.
-Customized Quarkus layouts can provide an exact optional override:
+`url` is the exact metrics endpoint, normally `/q/metrics`. Optional SmallRye
+Health is collected independently; unavailable health does not discard valid
+metrics or synthesize stored application health. Successful metrics collection
+without explicit health can display `UP` with a collection-based dashboard hint.
-```yaml
-targets:
- - name: "orders"
- type: "quarkus"
- url: "http://localhost:9000/manage/prom"
- health_url: "http://localhost:9000/manage/health"
-```
-
-`health_url` is optional and accepted for Quarkus and Micronaut targets. The target's
-Basic Auth configuration applies to both metrics and health requests.
-
-If the derived `/q/health` endpoint returns `404`, StatLite treats SmallRye
-Health as absent, keeps the metrics poll quiet, and leaves application health
-unavailable. A successful metrics scrape is still shown as `UP`, with the
-dashboard hint identifying successful metrics collection as the source. A
-current collection failure is shown as `DOWN`; the underlying collection
-states remain reporting and unavailable. That absence is cached for the
-collector session. Health discovery resumes when
-the observed process-start identity changes, when that identity is available,
-or when the collector is recreated.
-
-The adapter normalizes only these existing StatLite concepts: HTTP request
-count, request duration, 404/4xx/5xx counts, process CPU ratio, heap used bytes,
-process start time, and optional uptime. Request dimensions, histogram buckets,
-exemplars, timestamps, and unrelated metric families are discarded before
-persistence. HTTP meters can be absent while an idle application remains
-compatible when a finite CPU, heap, or process-start family is present.
-
-When published, Quarkus targets normalize overall SmallRye Health status and
-aggregate Quarkus datasource health checks into `db_health_status`. Database
-health stays unavailable when the application publishes no datasource check.
-Host resources are not inferred or populated. Missing optional concepts produce
-partial data; an endpoint without a usable required runtime family is
-incompatible.
+See the [Quarkus target reference](targets/quarkus.md) for health derivation and
+overrides, compatibility, normalized metrics, and the tested setup.
### Micronaut Micrometer metrics
-Run the [Micronaut demo](../examples/micronaut-metrics-demo/) for a complete
-application, management configuration, and deterministic traffic recipe.
-
```yaml
targets:
- name: "orders"
@@ -340,128 +278,12 @@ targets:
url: "http://localhost:8080/prometheus"
```
-Collection requests the exact `url`, including its path, trailing slash, and
-query, without endpoint discovery. Omitted `type` still defaults to Spring.
-Spring-only `actuator_base_url`, `metrics_source`, and `collect_host_metrics`
-are rejected for Micronaut, including explicit empty/false values. Host metrics
-are not inferred from application metrics.
-
-The primary tested graph uses platform parent 5.2.1, Micronaut core 5.2.11,
-Micronaut Micrometer 6.1.0, and Micrometer 1.17.1. The same adapter also passes
-the retained 4.9.9 regression (parent 4.9.2, Micronaut Micrometer 5.12.0,
-Micrometer 1.15.0), which the public Java 21 demo exercises. In an application
-using either platform, include these dependencies:
-
-```xml
-
- io.micronaut
- micronaut-management
-
-
- io.micronaut.micrometer
- micronaut-micrometer-core
-
-
- io.micronaut.micrometer
- micronaut-micrometer-registry-prometheus
-
-```
-
-Enable metrics and permit access to the Prometheus endpoint in the application's
-configuration. The minimal certified graph uses properties:
-
-```properties
-micronaut.metrics.enabled=true
-endpoints.prometheus.sensitive=false
-```
-
-Certification runs with Eclipse Temurin 25.0.4.1+1-LTS. The 5.x fixture uses
-Java 25 bytecode; the 4.9.9 regression fixture uses Java 17 bytecode. These are tested versions, not a claim that every runtime or
-Micrometer setup is compatible. No Prometheus server or Grafana is required.
-Removing management from this graph removes both `/prometheus` and `/health`.
-To retain metrics with absent health, keep management installed and set
-`endpoints.health.enabled=false`.
-
-Only existing StatLite concepts are normalized:
-
-| StatLite sample | Micronaut source |
-| --- | --- |
-| `http_requests_total` | Sum `http_server_requests_seconds_count` |
-| `http_404_total` | Count with exact status 404 |
-| `http_4xx_total` | Count with status 400–499, including 404 |
-| `http_5xx_total` | Count with status 500–599 |
-| `http_request_time_total_seconds` | Sum `_sum` with matching accepted count/sum identities |
-| `process_cpu_usage` | Finite `process_cpu_usage` ratio from 0 to 1 |
-| `jvm_heap_used_bytes` | Sum nonnegative `jvm_memory_used_bytes{area="heap"}` |
-| `process_start_time` | Valid `process_start_time_seconds`, also used for restart identity |
-| `process_uptime` | Finite nonnegative `process_uptime_seconds` |
-
-Both HTTP count and sum require nonempty `method`, `status`, `uri`, and
-`exception` labels. Status is exactly three ASCII digits from 100 through 599.
-Count/sum matching uses the entire source label identity; extra labels take part
-in matching but are not stored. Matching state is bounded to 20,000 combined
-identities. Invalid or duplicate series, mismatches, and overflowing aggregates
-omit affected concepts and record focused warnings. Independent valid concepts
-remain usable. Missing optional families do not warn. Compatibility requires a
-valid CPU, heap, or process-start concept; uptime or HTTP metrics alone are
-insufficient. Runtime-only idle exposition is compatible.
-
-HTTP timers are absent before the first completed request at startup and
-restart, so HTTP samples remain unavailable until meters exist. Once present,
-counters include application and management requests, including scrapes,
-health requests, and inspection probes. Scrape timing differs: the tested 4.9.9
-setup excludes its own unfinished request, while 5.x can record the scrape timer
-before generating the response body. The first scrape can therefore already
-contain real HTTP samples. StatLite stores raw cumulative counters and derives
-nonnegative deltas at query time; process-start changes retain the existing
-restart boundary semantics. Routes, exceptions, arbitrary labels, histogram
-buckets, and percentiles are not stored.
-
-Health is an independent optional request after the metrics attempt. For a
-literal `/prometheus` suffix with at most one trailing slash, StatLite derives
-same-origin `/health`, preserves the escaped context prefix, and removes the
-query. A custom metrics path requires an explicit override for health:
-
-```yaml
-targets:
- - name: "orders"
- type: "micronaut"
- url: "http://localhost:8080/manage/metrics"
- health_url: "http://localhost:8080/manage/health"
-```
-
-`health_url` retains its exact path/query and may specify a different origin.
-The target's Basic Auth applies to both endpoints. Redirects stay within each
-endpoint's origin, and requests share the configured poll timeout and existing
-body limits. When a health endpoint is configured or derived, StatLite attempts
-at most one logical health request per poll unless derived health is cached
-absent.
-
-Application health requires a valid aggregate UP/DOWN `status` over HTTP 200 or
-503. Unknown status, malformed payloads, or fetch errors leave health unavailable
-and warn without discarding good metrics. Database health requires visible
-`details.jdbc.status` with UP/DOWN. Absent or hidden details leave DB health
-unavailable; invalid optional JDBC details warn while preserving valid app
-health. Nested datasource details are ignored, and overall app health never
-fills in DB health. The primary 5.x JDBC setup uses `micronaut-jdbc-hikari` 7.2.0
-and H2 2.5.250; the retained 4.9.9 regression uses 6.2.1 and H2 2.3.232.
-Exposing JDBC details to anonymous clients requires:
-
-```properties
-endpoints.health.details-visible=ANONYMOUS
-```
+Use explicit `type: "micronaut"`; omitted `type` still defaults to Spring.
+`url` is the exact metrics endpoint, normally `/prometheus`. The application
+requires management and Micrometer configuration.
-The default authenticated visibility hides those details from anonymous
-requests. Health absence never fabricates stored application or DB status.
-Successful collection with unavailable explicit health can still display
-operational `UP` on the dashboard, with its existing collection-based hint.
-
-An initial derived-health 404 with compatible metrics is quiet and cached until
-a detectable process-start change or collector recreation. There is no periodic
-reprobe. Enabling health without a detectable restart requires collector
-recreation to discover it. After health was available, its loss warns and is
-retried each poll. Explicit overrides always probe and warn on 404. These rules
-preserve usable metrics and avoid carrying forward stale health values.
+See the [Micronaut target reference](targets/micronaut.md) for application
+setup, tested versions, metric mapping, and optional management/JDBC health.
### Basic Auth
diff --git a/docs/integration-testing.md b/docs/integration-testing.md
index 60e945d..7540c96 100644
--- a/docs/integration-testing.md
+++ b/docs/integration-testing.md
@@ -49,13 +49,13 @@ The shared matrix currently exercises:
[`statlite-metrics/v1` guide](integrate/go/net-http.md).
- [Gin](../examples/go-gin-demo/) through the
[`statlite-metrics/v1` guide](integrate/go/gin.md).
-- [Spring Boot](../examples/spring-actuator-demo/) through the Actuator
- integration.
-- [Quarkus](../examples/quarkus-metrics-demo/) through the Micrometer metrics
- integration.
+- [Spring Boot](../examples/spring-actuator-demo/) through the [Spring target](targets/spring.md)
+ Actuator integration.
+- [Quarkus](../examples/quarkus-metrics-demo/) through the [Quarkus target](targets/quarkus.md)
+ Micrometer metrics integration.
- [Micronaut](../examples/micronaut-metrics-demo/) through the
- [Micrometer metrics integration](configuration.md#micronaut-micrometer-metrics).
+ [Micrometer metrics integration](targets/micronaut.md).
Inspect the implementation and current triggers in the public
[`integration certification` workflow](https://github.com/PVRLabs/statlite/actions/workflows/integration-certification.yml).
diff --git a/docs/integrations.md b/docs/integrations.md
index a19593b..6006062 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -14,7 +14,8 @@ not a generic Prometheus scraper or metrics database.
| Spring Boot Actuator | Supported | Actuator JSON | `/actuator` management base URL | Actuator health is the normal Spring health source; application request, JVM, process, and optional host concepts are normalized into StatLite's fixed vocabulary. |
| Spring Micrometer Prometheus | Supported | Prometheus/OpenMetrics exposition | Configured Prometheus endpoint | This is a Spring source option, not a generic `prometheus` target. |
| Quarkus 3.39.x | Supported | Micrometer Prometheus/OpenMetrics exposition; optional SmallRye Health | Conventional `/q/metrics`; optional `/q/health` | Explicit `quarkus` target; datasource health is normalized when published. |
-| Micronaut 4.9.9 (certified setup) | Supported | Micrometer 1.15.0 Prometheus exposition; optional management health | Exact `/prometheus`; optional `/health` | Explicit `micronaut` target; JDBC health requires visible aggregate details. |
+| [Micronaut 5.2.11 (tested setup)](targets/micronaut.md#tested-versions) | Supported | Micrometer 1.17.1 Prometheus exposition; optional management health | Exact `/prometheus`; optional `/health` | Primary tested setup; explicit `micronaut` target; JDBC health requires visible aggregate details. |
+| [Micronaut 4.9.9 (tested setup)](targets/micronaut.md#tested-versions) | Supported | Micrometer 1.15.0 Prometheus exposition; optional management health | Exact `/prometheus`; optional `/health` | Retained regression setup used by the public demo and CI; JDBC health requires visible aggregate details. |
| StatLite Metrics v1 | Supported | Fixed `statlite-metrics/v1` response | `/statlite/metrics` | Fixed producer contract for StatLite and compatible applications. See the [direct integration guides](integrate/). |
Support means that the integration has an owned endpoint and source contract,
@@ -36,90 +37,39 @@ examples are exercised in the shared public workflow.
Configure Spring applications with `type: spring` or omit `type` for the
default. The `url` is the Actuator management base URL. Spring can use its
-Actuator source, its Micrometer Prometheus source, or the configured automatic
-source selection described in [configuration](configuration.md). Health is an
+Actuator source, its Micrometer Prometheus source, or
+[automatic source selection](targets/spring.md#metrics-source-and-health). Health is an
independent authoritative Actuator signal. If its retrieval fails, StatLite
leaves health unavailable, retains independently usable metrics, and records a
focused warning. A poll still requires at least one usable metric sample to
count as reporting.
+See the [Spring target reference](targets/spring.md) for configuration,
+source selection, health, and remote host metrics.
+
## Quarkus
-Quarkus is a framework-first `type: quarkus` target. Its `url` is the exact
-metrics exposition endpoint, conventionally
-`http://localhost:9000/q/metrics`, rather than a management base URL. The
-adapter accepts only the documented, bounded Quarkus/Micrometer contract; it
-does not persist arbitrary source dimensions or infer host metrics from a
-successful scrape. SmallRye Health is an optional Quarkus capability. For a
-conventional Quarkus metrics path ending in `/q/metrics`, StatLite derives the
-sibling `/q/health` endpoint where practical, requests it separately when
-available, and normalizes the overall and datasource statuses. A missing health
-capability is quiet: aggregate framework health is unavailable, while a
-successful metrics scrape is presented as `UP` on the dashboard. Its hint
-explains that `UP` is derived from successful metrics collection rather than
-explicit application health. A current collection failure is presented as
-`DOWN`. These are presentation labels only: reporting and unavailable remain
-the underlying collection concepts, and StatLite does not synthesize stored
-health values.
-Database health remains unavailable unless a datasource check is published.
-The absent capability is cached until the observed process-start identity
-changes when available, or the collector is recreated. A known health failure
-can record a focused warning without discarding valid metrics. For a customized
-layout, set `health_url` as an optional override; a custom metrics path without
-that override remains a supported metrics-only target.
-
-The normalized concepts are HTTP request count and duration, 404/4xx/5xx
-counts, process CPU, heap used, process start time, and optional uptime. HTTP
-meters are lazy, so an idle endpoint can be compatible with finite runtime
-families alone. Typed inspection accepts either an application base URL or an
-exact customized metrics endpoint. Untyped inspection probes only the
-established `/q/metrics` location; it does not identify arbitrary Micrometer
-exposition as Quarkus. Basic Auth uses the shared `auth.type: basic`
-configuration for both endpoints.
+Quarkus is a first-class `type: quarkus` target with an exact metrics endpoint,
+normally `/q/metrics`, and optional SmallRye Health. Support covers StatLite's
+bounded Quarkus/Micrometer contract, including datasource health when published.
+See the [Quarkus target reference](targets/quarkus.md) for normalized concepts,
+compatibility, health behavior, custom endpoints, inspection, and the pinned
+Quarkus 3.39.1 / Java 21 fixture.
+
+## Micronaut
+
+Micronaut is a first-class explicit `type: micronaut` target with an exact
+metrics endpoint, normally `/prometheus`, and optional management health.
+Support covers the named 5.2.11 and 4.9.9 setups and fixed contract, not arbitrary
+Micrometer exposition. Quarkus and Micronaut retain separate label and health
+contracts. See the [Micronaut target reference](targets/micronaut.md) for
+application setup, tested versions, normalization, JDBC health visibility,
+custom endpoints, and typed inspection.
Spring Boot, Quarkus, and Micronaut memory is JVM heap used. It is runtime-managed
application memory, not process RSS, container memory, or a configured maximum
heap size.
-The public pinned fixture is
-[`examples/quarkus-metrics-demo/`](../examples/quarkus-metrics-demo/). It uses
-Quarkus 3.39.1, Java 21 LTS, the Micrometer Prometheus registry, and SmallRye
-Health.
-
-## Micronaut
-
-Micronaut uses an explicit `type: micronaut` target and the exact metrics
-endpoint, conventionally `http://localhost:8080/prometheus`. The certified
-setup uses Micronaut 4.9.9 (platform parent 4.9.2), Micronaut Micrometer 5.12.0,
-Micrometer 1.15.0, and optional JDBC Hikari integration 6.2.1. Certification
-uses Temurin 25.0.4.1+1-LTS with Java 17 fixture bytecode and H2 2.3.232.
-Support covers this setup and the fixed contract, not arbitrary Micrometer
-exposition. Quarkus and Micronaut retain separate label and health contracts.
-
-The adapter normalizes request count, 404/4xx/5xx counts, accumulated duration,
-process CPU, JVM heap used, process start, and uptime. HTTP timers are lazy at
-idle and restart, and include management self-traffic once requests complete.
-Missing timers remain unavailable. Source routes, exceptions, datasource names,
-and other labels are not stored as dimensions. Invalid concepts produce focused
-partial warnings while independent valid metrics remain usable.
-
-Management health is independent and optional. A conventional `/prometheus`
-path derives sibling `/health`; custom paths can use `health_url`. Database
-health requires visible `details.jdbc.status`, and remains unavailable when
-JDBC details are absent or hidden. UP/DOWN aggregate application health is
-accepted over HTTP 200 or 503. Metrics success does not synthesize stored health.
-As with Quarkus, successful metrics without explicit health can appear as
-operational `UP` on the dashboard. Initial derived-health absence is cached until
-a detectable process restart or collector recreation; known failures warn and
-are retried. Disabling health can leave metrics usable; removing management
-from the certified dependency graph removes both endpoints.
-
-Use `statlite inspect --type micronaut` with a base URL or exact endpoint.
-Inspection checks the same metrics contract as collection and does not probe
-health. It does not prove Micronaut identity, and untyped inspection does not
-attempt Micronaut recognition. See [configuration and setup](configuration.md#micronaut-micrometer-metrics)
-for dependencies, endpoint resolution, health visibility, and overrides.
-
## Scope boundaries
For applications without a first-class framework target, use the [StatLite
diff --git a/docs/targets/micronaut.md b/docs/targets/micronaut.md
new file mode 100644
index 0000000..3e75130
--- /dev/null
+++ b/docs/targets/micronaut.md
@@ -0,0 +1,186 @@
+# Micronaut target
+
+Configure the exact Micronaut Prometheus endpoint with explicit
+`type: "micronaut"`. Management and Micrometer configuration is required.
+
+See [Configuration](../configuration.md) for shared server, storage, polling,
+and [Basic Auth](../configuration.md#basic-auth) settings. Add the target entry
+to the `targets` list in your configuration.
+
+## Basic configuration
+
+Run the [Micronaut demo](../../examples/micronaut-metrics-demo/) for a complete
+application, management configuration, and deterministic traffic recipe.
+
+```yaml
+targets:
+ - name: "orders"
+ type: "micronaut"
+ url: "http://localhost:8080/prometheus"
+```
+
+## Application-side Micrometer setup
+
+Include these dependencies in the application:
+
+```xml
+
+ io.micronaut
+ micronaut-management
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-core
+
+
+ io.micronaut.micrometer
+ micronaut-micrometer-registry-prometheus
+
+```
+
+Enable metrics and permit access to the Prometheus endpoint in the application's
+configuration. The minimal certified graph uses properties:
+
+```properties
+micronaut.metrics.enabled=true
+endpoints.prometheus.sensitive=false
+```
+
+## Tested versions
+
+Both setups below are supported. The primary tested graph uses platform parent
+5.2.1, Micronaut core 5.2.11,
+Micronaut Micrometer 6.1.0, and Micrometer 1.17.1. The same adapter also passes
+the retained 4.9.9 regression (parent 4.9.2, Micronaut Micrometer 5.12.0,
+Micrometer 1.15.0). The public demo and CI retain the 4.9.9 / Java 21 setup.
+
+Tested with Temurin 25.0.4.1+1-LTS, using Java 25 bytecode for 5.x and Java 17
+for 4.9.9. Compatibility covers only the tested runtime and Micrometer setups. No Prometheus server or Grafana is required.
+Removing management from this graph removes both `/prometheus` and `/health`.
+To retain metrics with absent health, keep management installed and set
+`endpoints.health.enabled=false`.
+
+## Metrics StatLite consumes
+
+Only existing StatLite concepts are normalized:
+
+| StatLite sample | Micronaut source |
+| --- | --- |
+| `http_requests_total` | Sum `http_server_requests_seconds_count` |
+| `http_404_total` | Count with exact status 404 |
+| `http_4xx_total` | Count with status 400–499, including 404 |
+| `http_5xx_total` | Count with status 500–599 |
+| `http_request_time_total_seconds` | Sum `_sum` with matching accepted count/sum identities |
+| `process_cpu_usage` | Finite `process_cpu_usage` ratio from 0 to 1 |
+| `jvm_heap_used_bytes` | Sum nonnegative `jvm_memory_used_bytes{area="heap"}` |
+| `process_start_time` | Valid `process_start_time_seconds`, also used for restart identity |
+| `process_uptime` | Finite nonnegative `process_uptime_seconds` |
+
+Both HTTP count and sum require nonempty `method`, `status`, `uri`, and
+`exception` labels. Status is exactly three ASCII digits from 100 through 599.
+Count/sum matching uses the entire source label identity; extra labels take part
+in matching but are not stored. Matching state is bounded to 20,000 combined
+identities. Invalid or duplicate series, mismatches, and overflowing aggregates
+omit affected concepts and record focused warnings. Independent valid concepts
+remain usable. Missing optional families do not warn. Compatibility requires a
+valid CPU, heap, or process-start concept; uptime or HTTP metrics alone are
+insufficient. Runtime-only idle exposition is compatible.
+
+Memory is JVM heap used, not process RSS, container memory, or a configured
+maximum heap size.
+
+## HTTP metric behavior
+
+HTTP timers are absent before the first completed request at startup and
+restart, so HTTP samples remain unavailable until meters exist. Once present,
+counters include application and management requests, including scrapes,
+health requests, and inspection probes. Scrape timing differs: the tested 4.9.9
+setup excludes its own unfinished request, while 5.x can record the scrape timer
+before generating the response body. The first scrape can therefore already
+contain real HTTP samples. StatLite stores raw cumulative counters and derives
+nonnegative deltas at query time; process-start changes retain the existing
+restart boundary semantics. Routes, exceptions, arbitrary labels, histogram
+buckets, and percentiles are not stored.
+
+## Health, JDBC health, and custom endpoints
+
+Health is an independent optional request after the metrics attempt. For a
+literal `/prometheus` suffix with at most one trailing slash, StatLite derives
+same-origin `/health`, preserves the escaped context prefix, and removes the
+query. A custom metrics path requires an explicit override for health:
+
+```yaml
+targets:
+ - name: "orders"
+ type: "micronaut"
+ url: "http://localhost:8080/manage/metrics"
+ health_url: "http://localhost:8080/manage/health"
+```
+
+`health_url` retains its exact path/query and may specify a different origin.
+The target's Basic Auth applies to both endpoints. Redirects stay within each
+endpoint's origin, and requests share the configured poll timeout and existing
+body limits. When a health endpoint is configured or derived, StatLite attempts
+at most one logical health request per poll unless derived health is cached
+absent.
+
+Application health requires a valid aggregate UP/DOWN `status` over HTTP 200 or
+503. Unknown status, malformed payloads, or fetch errors leave health unavailable
+and warn without discarding good metrics. Database health requires visible
+`details.jdbc.status` with UP/DOWN. Absent or hidden details leave DB health
+unavailable; invalid optional JDBC details warn while preserving valid app
+health. Nested datasource details are ignored, and overall app health never
+fills in DB health. The primary 5.x JDBC setup uses `micronaut-jdbc-hikari` 7.2.0
+and H2 2.5.250; the retained 4.9.9 regression uses 6.2.1 and H2 2.3.232.
+Exposing JDBC details to anonymous clients requires:
+
+```properties
+endpoints.health.details-visible=ANONYMOUS
+```
+
+The default authenticated visibility hides those details from anonymous
+requests. Health absence never fabricates stored application or DB status.
+Successful collection with unavailable explicit health can still display
+operational `UP` on the dashboard, with its existing collection-based hint.
+
+## Health caching and retries
+
+An initial derived-health 404 with compatible metrics is quiet and cached until
+a detectable process-start change or collector recreation. There is no periodic
+reprobe. Enabling health without a detectable restart requires collector
+recreation to discover it. After health was available, its loss warns and is
+retried each poll. Explicit overrides always probe and warn on 404. These rules
+preserve usable metrics and avoid carrying forward stale health values.
+
+## Exact collection URLs and configuration limits
+
+Collection requests the exact `url`, including its path, trailing slash, and
+query, without endpoint discovery. Omitted `type` still defaults to Spring.
+Spring-only `actuator_base_url`, `metrics_source`, and `collect_host_metrics`
+are rejected for Micronaut, including explicit empty/false values. Host metrics
+are not inferred from application metrics.
+
+## Target inspection
+
+```bash
+statlite inspect --type micronaut 'http://localhost:8080'
+statlite inspect --type micronaut 'http://localhost:8080/prometheus'
+statlite inspect --type micronaut 'http://localhost:8080/service'
+```
+
+A root base URL resolves to `/prometheus`. A non-root path is tried exactly;
+after HTTP 404/410 or parsed incompatible metrics, one context-path fallback
+appends `/prometheus`. Conventional `/prometheus` paths and URLs with a query
+(including a bare `?`) are exact and have no fallback. Escaped paths and queries
+are preserved. Authentication failures, malformed responses, timeouts, and other
+inconclusive failures stop resolution. Inspection has a five-second overall
+deadline and at most two scrapes, each with at most three same-origin redirects.
+It uses the runtime metrics evaluator, reports `partial` for compatible scrapes
+with warnings, and does not request health. Compatibility does not prove
+Micronaut identity. Untyped inspection adds no Micronaut probe or inference from
+arbitrary Micrometer exposition. Inspection has no new authentication options;
+configure authenticated targets manually with the shared Basic Auth block.
+
+See [target inspection](../configuration.md#discover-a-target-with-inspect) for
+shared options and configuration write modes, and
+[Public integration testing](../integration-testing.md) for the demo checks.
diff --git a/docs/targets/quarkus.md b/docs/targets/quarkus.md
new file mode 100644
index 0000000..bcc109f
--- /dev/null
+++ b/docs/targets/quarkus.md
@@ -0,0 +1,109 @@
+# Quarkus target
+
+Configure the exact Quarkus Micrometer metrics endpoint with `type: "quarkus"`.
+
+See [Configuration](../configuration.md) for shared server, storage, polling,
+and [Basic Auth](../configuration.md#basic-auth) settings. Add the target entry
+to the `targets` list in your configuration.
+
+## Basic configuration
+
+```yaml
+targets:
+ - name: "orders"
+ type: "quarkus"
+ url: "http://localhost:9000/q/metrics"
+```
+
+## Endpoints and tested setup
+
+For Quarkus, `url` is the conventional `/q/metrics` Prometheus/OpenMetrics
+endpoint, not a management base URL. StatLite derives the aggregate SmallRye
+Health endpoint by replacing `/q/metrics` with `/q/health` on the same origin
+and context path when the conventional capability is available. It performs
+one bounded health request and one bounded metrics scrape per polling cycle
+when health is configured or conventionally available, and uses the poll time
+rather than exposition timestamps. The pinned fixture includes Quarkus 3.39.1
+with Java 21 LTS, `quarkus-micrometer-registry-prometheus`, and the optional
+`quarkus-smallrye-health` extension.
+
+## Health and custom endpoints
+
+Health collection is best-effort and independent from metrics collection. If
+the derived `/q/health` endpoint is absent, aggregate framework health is
+unavailable. A successful metrics scrape is shown as `UP` on the dashboard,
+with its hint explaining that the label is based on collection rather than an
+explicit application-health assertion. Internally this remains reporting
+availability; StatLite does not synthesize or store application health `UP`.
+Database health remains unavailable without a datasource check. The absent
+capability is quiet and does not produce a recurring warning. A known or
+explicitly configured endpoint that returns an invalid or failed response may
+produce a focused warning without discarding valid metrics. Exact custom
+metrics paths remain supported; when the path is not a conventional
+`/q/metrics` path, StatLite does not infer a health endpoint.
+Customized Quarkus layouts can provide an exact optional override:
+
+```yaml
+targets:
+ - name: "orders"
+ type: "quarkus"
+ url: "http://localhost:9000/manage/prom"
+ health_url: "http://localhost:9000/manage/health"
+```
+
+`health_url` is optional and accepted for Quarkus and Micronaut targets. The target's
+Basic Auth configuration applies to both metrics and health requests.
+
+If the derived `/q/health` endpoint returns `404`, StatLite treats SmallRye
+Health as absent, keeps the metrics poll quiet, and leaves application health
+unavailable. A successful metrics scrape is still shown as `UP`, with the
+dashboard hint identifying successful metrics collection as the source. A
+current collection failure is shown as `DOWN`; the underlying collection
+states remain reporting and unavailable. That absence is cached for the
+collector session. Health discovery resumes when
+the observed process-start identity changes, when that identity is available,
+or when the collector is recreated.
+
+## Normalized metrics and compatibility
+
+The adapter normalizes only these existing StatLite concepts: HTTP request
+count, request duration, 404/4xx/5xx counts, process CPU ratio, heap used bytes,
+process start time, and optional uptime. Request dimensions, histogram buckets,
+exemplars, timestamps, and unrelated metric families are discarded before
+persistence. HTTP meters can be absent while an idle application remains
+compatible when a finite CPU, heap, or process-start family is present.
+
+When published, Quarkus targets normalize overall SmallRye Health status and
+aggregate Quarkus datasource health checks into `db_health_status`. Database
+health stays unavailable when the application publishes no datasource check.
+Host resources are not inferred or populated. Missing optional concepts produce
+partial data; an endpoint without a usable required runtime family is
+incompatible.
+
+Memory is JVM heap used, not process RSS, container memory, or a configured
+maximum heap size. StatLite accepts the bounded Quarkus/Micrometer contract,
+not arbitrary Prometheus exposition.
+
+## Target inspection
+
+```bash
+statlite inspect --type quarkus 'http://localhost:9000'
+statlite inspect --type quarkus 'http://localhost:9000/q/metrics'
+```
+
+Typed Quarkus inspection accepts only StatLite's bounded Quarkus contract, not
+arbitrary Prometheus or Micrometer exposition. A root application URL resolves
+to `/q/metrics`. A non-root URL is tried first as an exact endpoint and then,
+after a conclusive miss, with `/q/metrics` appended. A URL containing a query
+string is always an exact endpoint and is preserved.
+
+Untyped inspection probes the established `/q/metrics` location; it does not
+identify arbitrary Micrometer exposition as Quarkus. See
+[target inspection](../configuration.md#discover-a-target-with-inspect) for
+shared inspection options and configuration write modes.
+
+## Example and testing
+
+The [pinned Quarkus fixture](../../examples/quarkus-metrics-demo/) includes
+contract captures and a traffic recipe. See
+[Public integration testing](../integration-testing.md) for the shipped checks.
diff --git a/docs/targets/spring.md b/docs/targets/spring.md
new file mode 100644
index 0000000..7c9a51e
--- /dev/null
+++ b/docs/targets/spring.md
@@ -0,0 +1,91 @@
+# Spring Boot target
+
+Spring Boot is StatLite's primary framework integration. Configure its Actuator
+management base URL with `type: "spring"`.
+
+See [Configuration](../configuration.md) for shared server, storage, polling,
+and [Basic Auth](../configuration.md#basic-auth) settings. Add the target entry
+to the `targets` list in your configuration.
+
+## Basic configuration
+
+```yaml
+targets:
+ - name: "my-app"
+ type: "spring"
+ url: "https://example.com/actuator"
+ metrics_source: "auto"
+ auth:
+ type: "basic"
+ username: "admin"
+ password: "change-me"
+```
+
+## Management base URL
+
+`url` is the Spring management base URL. StatLite derives the health and
+metrics endpoints from it. `type` may be omitted for compatibility with older
+Spring configurations, but new configuration should use explicit
+`type: "spring"`.
+
+## Metrics source and health
+
+`metrics_source` accepts `auto`, `prometheus`, or `actuator` and defaults to
+`auto` when omitted. Use `prometheus` to select the Spring Micrometer
+Prometheus/OpenMetrics source explicitly, or `actuator` to select Actuator JSON.
+
+`auto` prefers a compatible Spring Prometheus endpoint and
+falls back to Actuator metrics only when the endpoint is absent or returns a
+valid but incompatible exposition. Authentication, transient, malformed, and
+resource-limit failures are retried without changing sources. Once selected,
+the source remains fixed until the target collector is recreated. Health is
+collected independently from the Actuator health endpoint. If health retrieval
+fails but at least one independently usable metric sample is collected, the
+poll remains successful, health stays unavailable, and StatLite records a
+focused warning. If no usable metric sample is collected, the poll fails even
+when health responded.
+
+Missing optional metrics are handled gracefully: values may appear as `null` or charts may show gaps instead of failing the whole poll.
+
+## Remote host metrics
+
+Host metrics are disabled for Spring targets by default. In the common
+single-host setup, monitor host CPU, memory, and disk through the
+`statlite-self` target instead. For a remote Spring Boot application where
+running StatLite on that host is undesirable, enable Actuator host collection
+for that target:
+
+```yaml
+targets:
+ - name: "remote-app"
+ type: "spring"
+ url: "https://remote.example.com/actuator"
+ collect_host_metrics: true
+```
+
+This adds polls for `system.cpu.usage`, `disk.free`, and `disk.total`. The
+resulting CPU and disk values describe the execution environment visible to
+the Spring Boot process, which may be a container rather than the physical
+host.
+
+## Authentication and deprecated configuration
+
+Spring endpoints use the target's `auth.type: "basic"` configuration. See
+[Basic Auth](../configuration.md#basic-auth) for environment-variable credentials
+and shared authentication settings.
+
+`actuator_base_url` is deprecated; use `url`. See
+[Deprecations and compatibility](../deprecations.md#actuator_base_url) for its
+temporary compatibility behavior, including legacy embedded credentials.
+
+## Examples and references
+
+Run the [Spring Boot demo](../../examples/spring-actuator-demo/) for a standalone
+application, traffic recipe, and restart test. The
+[Actuator example configuration](../../examples/actuator.yaml) includes Basic
+Auth placeholders. See [Supported integrations](../integrations.md#spring-boot)
+for supported Spring versions and [Public integration testing](../integration-testing.md)
+for the shipped fixture checks.
+
+Spring memory is JVM heap used, not process RSS, container memory, or the
+configured maximum heap size.
diff --git a/examples/micronaut-metrics-demo/README.md b/examples/micronaut-metrics-demo/README.md
index 87a937a..1fe9b15 100644
--- a/examples/micronaut-metrics-demo/README.md
+++ b/examples/micronaut-metrics-demo/README.md
@@ -31,7 +31,7 @@ go run ./cmd/statlite inspect --type micronaut http://127.0.0.1:18084
Typed inspection checks the supported metrics contract and resolves the base
URL to `/prometheus`. Compatibility does not establish framework identity.
-See [configuration](../../docs/configuration.md#micronaut-micrometer-metrics)
+See the [Micronaut target reference](../../docs/targets/micronaut.md)
for normalization and optional health behavior.
To run the CI journey, stop the manually started application first, then from
diff --git a/examples/micronaut.yaml b/examples/micronaut.yaml
index 1b7df4a..c43fda1 100644
--- a/examples/micronaut.yaml
+++ b/examples/micronaut.yaml
@@ -1,4 +1,4 @@
-# Requires the Micronaut setup documented in docs/configuration.md.
+# Requires the Micronaut setup documented in docs/targets/micronaut.md.
server:
listen: "127.0.0.1:9090"
storage:
diff --git a/examples/quarkus-metrics-demo/README.md b/examples/quarkus-metrics-demo/README.md
index 0883ff6..0758ffa 100644
--- a/examples/quarkus-metrics-demo/README.md
+++ b/examples/quarkus-metrics-demo/README.md
@@ -26,27 +26,7 @@ targets:
url: http://localhost:9000/q/metrics
```
-StatLite accepts the bounded Micrometer Prometheus/OpenMetrics contract and
-normalizes request count and duration, 404/4xx/5xx counts, process CPU, heap
-used, process start time, and optional uptime. HTTP meters are lazy, so an idle
-application can still be compatible through its runtime families. Memory is
-current JVM heap used, not process RSS, container memory, or the configured
-maximum heap size. Quarkus targets may provide application health through the
-optional SmallRye Health extension. Database health is available when the
-application publishes a datasource health check; this metrics-only fixture does
-not configure a datasource. Quarkus targets do not infer host resources. Untyped
-`statlite inspect` may discover Quarkus by probing the conventional `/q/metrics`
-location relative to the supplied application base URL, but it does not
-classify arbitrary Prometheus or Micrometer endpoints as Quarkus. Use
-`statlite inspect --type quarkus` for explicit framework-aware inspection,
-including custom or exact endpoints and endpoint resolution from a base URL.
-
-For a conventional Quarkus metrics URL ending in `/q/metrics`, StatLite derives
-the sibling `/q/health` endpoint when SmallRye Health is available. Health
-collection is best-effort and does not prevent valid metrics from being stored.
-If the capability is absent, aggregate framework health is unavailable, but a
-successful metrics scrape reports application reachability as health `UP`;
-database health remains unavailable unless a datasource check is published.
-The absence is quiet and cached until a changed process-start identity when
-available, or collector recreation. A customized metrics path can either
-remain metrics-only or configure an exact `health_url` override.
+See the [Quarkus target reference](../../docs/targets/quarkus.md) for the
+bounded metric contract, inspection, and independent optional health behavior.
+This metrics-only fixture does not configure a datasource, so database health
+is unavailable.
diff --git a/examples/spring-actuator-demo/README.md b/examples/spring-actuator-demo/README.md
index 974c7c7..70cd8ed 100644
--- a/examples/spring-actuator-demo/README.md
+++ b/examples/spring-actuator-demo/README.md
@@ -2,6 +2,9 @@
Minimal Spring Boot application for testing StatLite dashboards and collectors with Actuator and Micrometer metrics.
+See the [Spring target reference](../../docs/targets/spring.md)
+for configuration and collection semantics.
+
## Requirements
- Java 21
From 7abfe5d4f1d95895ca17de75c3a7299db2930077 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Thu, 1 Oct 2026 21:18:39 -0700
Subject: [PATCH 09/13] fix: preserve Micronaut health restart state (#56)
---
internal/collector/micronaut.go | 9 +++++++--
internal/collector/micronaut_health_test.go | 12 ++++++++++++
internal/inspect/micronaut_test.go | 9 +++++----
3 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/internal/collector/micronaut.go b/internal/collector/micronaut.go
index 5cf343a..c5c342a 100644
--- a/internal/collector/micronaut.go
+++ b/internal/collector/micronaut.go
@@ -135,7 +135,9 @@ func (c *MicronautCollector) markHealthAbsent(processStartTime *time.Time) {
c.healthStateMu.Lock()
defer c.healthStateMu.Unlock()
c.healthCapability = micronautHealthAbsent
- c.healthProcessStartTime = cloneTime(processStartTime)
+ if processStartTime != nil {
+ c.healthProcessStartTime = cloneTime(processStartTime)
+ }
}
func (c *MicronautCollector) health404IsOptional() bool {
@@ -148,7 +150,10 @@ func (c *MicronautCollector) markHealthAvailable(processStartTime *time.Time) {
c.healthStateMu.Lock()
defer c.healthStateMu.Unlock()
c.healthCapability = micronautHealthAvailable
- c.healthProcessStartTime = cloneTime(processStartTime)
+ // A failed or partial metrics scrape must not erase the restart baseline.
+ if processStartTime != nil {
+ c.healthProcessStartTime = cloneTime(processStartTime)
+ }
}
func (c *MicronautCollector) evaluate(ctx context.Context) (*micronautEvaluation, error) {
diff --git a/internal/collector/micronaut_health_test.go b/internal/collector/micronaut_health_test.go
index 8e96673..034c02c 100644
--- a/internal/collector/micronaut_health_test.go
+++ b/internal/collector/micronaut_health_test.go
@@ -189,6 +189,18 @@ func TestMicronautHealthCapabilityLifecycle(t *testing.T) {
optional bool
steps []step
}{
+ {"restart after metrics outage", true, []step{
+ {start: 1770000000, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 1},
+ {metricsFail: true, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 2},
+ {start: 1770000060, code: 404, probes: 3},
+ {start: 1770000060, code: 404, probes: 3},
+ }},
+ {"health loss after metrics outage without restart", true, []step{
+ {start: 1770000000, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 1},
+ {metricsFail: true, code: 200, body: `{"status":"UP"}`, app: "UP", probes: 2},
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 3},
+ {start: 1770000000, code: 404, warning: "health_fetch_failed", probes: 4},
+ }},
{"absent until restart", true, []step{
{start: 1770000000, code: 404, probes: 1},
{start: 1770000000, code: 200, body: `{"status":"UP"}`, probes: 1},
diff --git a/internal/inspect/micronaut_test.go b/internal/inspect/micronaut_test.go
index aa6ba43..34a4d98 100644
--- a/internal/inspect/micronaut_test.go
+++ b/internal/inspect/micronaut_test.go
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"reflect"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -315,15 +316,15 @@ http_server_requests_seconds_count{method="GET",status="200",uri="/",exception="
}
func TestMicronautInspectionDeadlineStopsFallback(t *testing.T) {
- requests := 0
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++; <-r.Context().Done() }))
+ var requests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); <-r.Context().Done() }))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
_, err := Inspect(ctx, TargetMicronaut, server.URL+"/context")
assertFailureKind(t, err, FailureIncomplete)
- if requests != 1 {
- t.Fatalf("requests=%d", requests)
+ if got := requests.Load(); got != 1 {
+ t.Fatalf("requests=%d", got)
}
}
From 343481d0b1ae28c4ef17403ac632e88e40133bc6 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Fri, 2 Oct 2026 09:22:49 -0700
Subject: [PATCH 10/13] docs: link existing API in monitoring options
---
docs/monitoring-options.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/docs/monitoring-options.md b/docs/monitoring-options.md
index 8d7ee20..cd96e31 100644
--- a/docs/monitoring-options.md
+++ b/docs/monitoring-options.md
@@ -266,11 +266,12 @@ small and focused.
### Alerts and automation
-StatLite does not currently send alerts or pages. A lightweight,
-machine-readable recent-events or API surface is being explored so cron jobs
-and external automation can react to StatLite events without requiring a full
-notification subsystem inside StatLite. Broader paging and escalation systems
-remain outside StatLite's intended scope.
+StatLite does not currently send alerts or pages. Its read-only [external
+API](api.md) exposes collection status, recent collector events, and derived
+metrics so cron jobs and external automation can react to StatLite events.
+Callers own their thresholds, schedules, deduplication, and notification
+delivery. Broader paging and escalation systems remain outside StatLite's
+intended scope.
## What StatLite deliberately leaves out
From ba4cc6917da1c8082e37e46e5bb14b1ff857f2f1 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Fri, 2 Oct 2026 11:16:35 -0700
Subject: [PATCH 11/13] docs: clarify diagnostic purpose and deployment
flexibility
---
docs/product.md | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/docs/product.md b/docs/product.md
index 6d534b4..8706676 100644
--- a/docs/product.md
+++ b/docs/product.md
@@ -14,7 +14,10 @@ useful, documented contract; they do not need feature parity with Spring.
It is intended for solo developers and small teams that need practical
production visibility without operating Prometheus and Grafana. StatLite is a
-focused production-support tool, not a general observability platform.
+focused production-support tool for understanding recent application and host
+behavior around operational problems. It does not provide external uptime
+guarantees or replace independent availability monitoring. It is not a general
+observability platform.
## Product principles
@@ -198,6 +201,12 @@ non-healthy states use warning or error styling.
## Deployment topology
+StatLite's small footprint, bounded metric set, and compact local SQLite history
+make collocated monitoring practical without requiring a separate metrics
+backend or monitoring host. Other deployment topologies are also valid when
+they better fit the environment. Independent monitoring is appropriate when
+external availability checks are required.
+
For a collocated deployment, configure application targets (`spring`, `quarkus`,
`micronaut`, or `statlite-metrics`) for application and process data, and `statlite-self`
through `/statlite/metrics` to monitor StatLite itself. The self response also
From d30ad1671f041ec49eb2a2b8e8c542f0d469cb04 Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Sun, 4 Oct 2026 18:32:08 -0700
Subject: [PATCH 12/13] docs: add Python integration landing page
---
docs/integrate/python/README.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 docs/integrate/python/README.md
diff --git a/docs/integrate/python/README.md b/docs/integrate/python/README.md
new file mode 100644
index 0000000..1038754
--- /dev/null
+++ b/docs/integrate/python/README.md
@@ -0,0 +1,15 @@
+# Integrate Python applications with StatLite
+
+StatLite supports lightweight, application-owned integrations for common Python
+web frameworks.
+
+| Framework | Integration guide | Runnable example |
+| --- | --- | --- |
+| FastAPI | [FastAPI guide](fastapi.md) | [FastAPI demo](../../../examples/python-fastapi-demo/) |
+| Django | [Django guide](django.md) | [Django demo](../../../examples/python-django-demo/) |
+
+Each guide includes a small helper and middleware you can copy into your
+application to expose `GET /statlite/metrics`. No StatLite SDK or additional
+third-party runtime dependency is required.
+
+See the [integration index](../README.md) for other integrations.
From a26a486d99ec2bb70528fa90a37df008771383ec Mon Sep 17 00:00:00 2001
From: Ted Kupolov
Date: Mon, 5 Oct 2026 08:37:56 -0700
Subject: [PATCH 13/13] release: prepare v0.6.0 Micronaut support (#58)
---
CHANGELOG.md | 13 +++++++++++++
internal/version/version.go | 2 +-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 516f319..e7a2ab9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ This file summarizes the main user-facing changes in each StatLite release.
GitHub release notes use the matching version section, with commit history as
a fallback when a section is missing.
+## v0.6.0 (2026-10-05)
+
+- Added first-class Micronaut monitoring with an explicit `micronaut` target,
+ Micrometer Prometheus metrics, optional management health, and typed
+ compatibility inspection. Certified Micronaut 5.2.11 as the primary runtime
+ and retained Micronaut 4.9.9 support.
+- Added a runnable Micronaut example and a public integration journey covering
+ metrics, health, inspection, and restart boundaries.
+- Preserved Micronaut health capability and restart detection through partial
+ metrics failures, with independent application and database health reporting.
+- Added canonical Spring, Quarkus, and Micronaut target references, a Python
+ integration landing page, and clearer deployment and Metrics v1 guidance.
+
## v0.5.1 (2026-09-30)
- Improved 7-day and 30-day dashboard chart performance with bounded sampling.
diff --git a/internal/version/version.go b/internal/version/version.go
index c2f12c8..da72493 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -2,4 +2,4 @@ package version
// Version is the StatLite build version.
// Release tags use vX.Y.Z. Development builds on main use a -dev suffix.
-var Version = "v0.5.2-dev"
+var Version = "v0.6.0"