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/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/README.md b/README.md index b643b79..97c26af 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![GitHub release](https://img.shields.io/github/v/release/PVRLabs/statlite)](https://github.com/PVRLabs/statlite/releases) [![Go powered](https://img.shields.io/github/go-mod/go-version/PVRLabs/statlite?label=Go%20powered&logo=go&logoColor=white)](go.mod) -[![Frameworks: Spring Boot · Quarkus](https://img.shields.io/badge/Frameworks-Spring%20Boot%20%C2%B7%20Quarkus-e7e7e7?labelColor=333)](docs/integrations.md) +[![Frameworks: Spring Boot · Quarkus · Micronaut](https://img.shields.io/badge/Frameworks-Spring%20Boot%20%C2%B7%20Quarkus%20%C2%B7%20Micronaut-e7e7e7?labelColor=333)](docs/integrations.md) [![CI](https://github.com/PVRLabs/statlite/actions/workflows/test.yml/badge.svg)](https://github.com/PVRLabs/statlite/actions/workflows/test.yml) [![License](https://img.shields.io/github/license/PVRLabs/statlite)](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 @@ -137,14 +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](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/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/cmd/statlite/main.go b/cmd/statlite/main.go index 826ddce..2b26050 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 @@ -359,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 @@ -375,7 +377,12 @@ 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. 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" @@ -457,6 +464,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 +520,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/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..0fbe911 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` @@ -69,11 +71,21 @@ 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: + +```bash +statlite inspect --type micronaut 'http://localhost:8080' +statlite inspect --type micronaut 'http://localhost:8080/prometheus' +statlite inspect --type micronaut 'http://localhost:8080/service' +``` + +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 @@ -178,7 +190,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 @@ -237,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 @@ -246,64 +261,29 @@ 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. + +See the [Quarkus target reference](targets/quarkus.md) for health derivation and +overrides, compatibility, normalized metrics, and the tested setup. + +### Micronaut Micrometer metrics ```yaml targets: - name: "orders" - type: "quarkus" - url: "http://localhost:9000/manage/prom" - health_url: "http://localhost:9000/manage/health" + type: "micronaut" + url: "http://localhost:8080/prometheus" ``` -`health_url` is optional and accepted only for Quarkus 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. +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. + +See the [Micronaut target reference](targets/micronaut.md) for application +setup, tested versions, metric mapping, and optional management/JDBC health. ### Basic Auth @@ -314,9 +294,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 +389,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/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. diff --git a/docs/integration-testing.md b/docs/integration-testing.md index 1cc4853..7540c96 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 @@ -46,10 +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](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). @@ -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/docs/integrations.md b/docs/integrations.md index e879d32..6006062 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -14,6 +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 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, @@ -35,56 +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. - -Spring Boot and Quarkus memory is JVM heap used. It is runtime-managed +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. - ## 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..cd96e31 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. @@ -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 diff --git a/docs/product.md b/docs/product.md index bc10e7a..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 @@ -83,7 +86,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 +97,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 +160,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. @@ -194,8 +201,14 @@ 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`, -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/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/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/README.md b/examples/README.md index 88b30bd..f3782e8 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 | @@ -25,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..1fe9b15 --- /dev/null +++ b/examples/micronaut-metrics-demo/README.md @@ -0,0 +1,52 @@ +# 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 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: + +```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 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 +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/examples/micronaut.yaml b/examples/micronaut.yaml new file mode 100644 index 0000000..c43fda1 --- /dev/null +++ b/examples/micronaut.yaml @@ -0,0 +1,13 @@ +# Requires the Micronaut setup documented in docs/targets/micronaut.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" 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 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/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..c5c342a --- /dev/null +++ b/internal/collector/micronaut.go @@ -0,0 +1,288 @@ +// This file validates and normalizes the explicitly selected Micronaut contract. +package collector + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "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 + 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. +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, 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, nil).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) { + 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, 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", "", metricsErr.Error()) + } + } else { + result.Samples = evaluation.samples + result.Events = append(result.Events, evaluation.events...) + result.ProcessStartTime = evaluation.processStartTime + } + + 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 + if processStartTime != nil { + 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 + // 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) { + 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_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..034c02c --- /dev/null +++ b/internal/collector/micronaut_health_test.go @@ -0,0 +1,292 @@ +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 + }{ + {"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}, + {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 new file mode 100644 index 0000000..7d4bf75 --- /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, nil) +} + +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, 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) + } + 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) + } +} 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/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..34a4d98 --- /dev/null +++ b/internal/inspect/micronaut_test.go @@ -0,0 +1,360 @@ +package inspect + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pvrlabs/statlite/internal/collector" + "github.com/pvrlabs/statlite/internal/prometheus" +) + +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) { + 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 got := requests.Load(); got != 1 { + t.Fatalf("requests=%d", got) + } +} + +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) + } +} 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") + } + } +} 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) 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" 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.'