Repository: lf-edge/eve
Pull request #4772: msrv: Add /metrics endpoint
Author: login: uncleDecart
Created: 2025-04-24T11:44:02Z | Merged: 2025-04-28T21:13:23Z

### Description

This pull request introduces a `/metrics` endpoint in EVE's metadata server service. This endpoint acts as a reverse proxy to the local `node_exporter` service, allowing AppInstances to retrieve host-level metrics in a standard Prometheus format.

- The `/metrics` endpoint proxies incoming HTTP requests directly to `node_exporter`, which is expected to be running on `localhost:9100`. This integration allows applications running inside AppInstances to collect detailed metrics about the host system without requiring special access or privileges.

- AppInstance can access these metrics by connecting to the following address: http://169.254.169.254/metrics
This IP address is reachable from within any container or VM that EVE runs through an interface connected to a local network instance. No public networking, DNS configuration, or special privileges are needed.

- To prevent abuse and ensure system stability, basic rate limiting is implemented. Each IP address is allowed up to one request per second, with a burst capacity of 10 requests. These limits are currently hardcoded.

The metrics endpoint behaves like a standard Prometheus target. One can consume it from any Prometheus-compatible agent, library, or application.

###  PR dependencies

This PR depends on #4774. It must be rebased after the NodeExporter service is merged to master.

### How to test


Deploy any AppInstance and from inside it run:

```
curl http://169.254.169.254/metrics
```

Expected output: Prometheus metrics =)

Confirm that rate limiting works. Rapidly sending multiple requests should eventually return `429 Too Many Requests` responses if the limit is exceeded.

```
for i in {1..20}; do curl -I http://169.254.169.254/metrics; sleep 0.05; done
```

You should see HTTP 429 responses after bursting over the allowed limits (the first 10 requests are handled, then 429).

### Changelog notes

New Feature: Added `/metrics` metadata server endpoint to proxy Prometheus metrics from `node_exporter` with basic rate limiting. Metrics are now accessible to AppInstances at `http://169.254.169.254/metrics`.

### Checklist

- [x]  I've provided a proper description
- [x]  I've added the proper documentation (when applicable)
- [x]  I've tested my PR on amd64 device(s)
- [ ]  I've tested my PR on arm64 device(s)
- [x]  I've written the test verification instructions
- [x]  I've set the proper labels to this PR



Diff:
diff --git a/docs/ECO-METADATA.md b/docs/ECO-METADATA.md
index 84876e6429a..d51cfaeaa6c 100644
--- a/docs/ECO-METADATA.md
+++ b/docs/ECO-METADATA.md
@@ -419,3 +419,45 @@ curl -X GET http://169.254.169.254/eve/v1/patch/download/699fbdb2-e455-448f-84f5
 
 %base64-encoded file contents%
 ```
+
+### Prometheus '/metrics' endpoint
+
+The /metrics endpoint provides access to system-level metrics from the EVE host where your EdgeApp is running.
+These metrics are sourced directly from a Node Exporter instance, expected to be available on localhost:9100 within the EVE host.
+
+When a client inside the EdgeApp makes a request to '/metrics', the EdgeApp transparently forwards the request to the Node Exporter
+and returns the response. The metrics are in Prometheus exposition format, making them compatible with Prometheus scraping and monitoring tools.
+
+>About Prometheus:
+[Prometheus](https://prometheus.io/) is an open-source systems monitoring and alerting toolkit. It collects and stores metrics as time series data, allowing users to query, visualize, and create alerts based on those metrics.
+
+#### Key Details
+
+- Target: Forwards all traffic internally to localhost:9100 on the EVE host.
+- Metrics Format: Prometheus-compatible plain text format.
+- Metrics Scope: Host-level metrics (not EdgeApp-specific).
+- Availability: Exposed within the EdgeApp.
+
+#### Rate Limiting
+
+To protect the system from overload, a *rate limiter* per client IP address is enforced:
+
+- Allowed Rate: 1 request per second.
+- Burst Capacity: Up to 10 requests can be handled immediately before rate limiting applies.
+- Idle Timeout: 4 minutes (if no requests are made from an IP for 4 minutes, the rate limit state is reset).
+
+Exceeding the allowed rate may result in the request being temporarily rejected with an appropriate HTTP error code (e.g., `429 Too Many Requests`).
+
+#### Usage Example
+
+```bash
+curl -X GET http://169.254.169.254/metrics
+```
+
+This command retrieves current host system metrics.
+
+#### Notes
+
+- Ensure that the Node Exporter service is running and accessible at localhost:9100 on the EVE host for this endpoint to function correctly.
+
+- This endpoint is intended for observability and monitoring purposes. Avoid frequent polling to respect rate limits and ensure system stability.
diff --git a/docs/PROMETHEUS_METRICS.md b/docs/PROMETHEUS_METRICS.md
index 675744cc69f..50596cf1049 100644
--- a/docs/PROMETHEUS_METRICS.md
+++ b/docs/PROMETHEUS_METRICS.md
@@ -11,6 +11,19 @@ From the EVE itself, you can access these metrics at:
 https://localhost:9100/metrics
 ```
 
+From an application instance, you can access these metrics at:
+
+```text
+http://169.254.169.254/metrics
+```
+
+This IP address is reachable from within any container or VM that EVE runs
+through an interface which is connected to a local network instance.
+
+The metrics endpoint behaves like a standard Prometheus target. You can consume
+it from any Prometheus-compatible agent, library, or application. This endpoint
+contains a request limiter of 1 rps with a burst of 10 for each IP address.
+
 ### Integration into EVE
 
 `node_exporter` is defined as a service in `rootfs.yml.in`. The container
@@ -26,6 +39,9 @@ consistent deployments.
 To modify how metrics are collected or adjust the exporter’s flags, edit the
 `CMD` in the `Dockerfile` or update bind mounts in build.yml.
 
+Regarding the metadata server usage for exposing metrics to applications, you
+can find the documentation in the [metadata server doc](./ECO-METADATA.md).
+
 #### iptables rules
 
 To enhance security, EVE configures iptables rules to restrict access to the
diff --git a/pkg/pillar/cmd/msrv/handlers.go b/pkg/pillar/cmd/msrv/handlers.go
index 2901e0103af..252c3ca8fc2 100644
--- a/pkg/pillar/cmd/msrv/handlers.go
+++ b/pkg/pillar/cmd/msrv/handlers.go
@@ -5,7 +5,6 @@ package msrv
 
 import (
 	"bytes"
-	"context"
 	"encoding/base64"
 	"encoding/binary"
 	"encoding/json"
@@ -13,6 +12,8 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"net/http/httputil"
+	"net/url"
 	"os"
 	"path"
 	"strings"
@@ -25,8 +26,6 @@ import (
 	"github.com/lf-edge/eve/pkg/pillar/zedcloud"
 )
 
-type middlewareKeys int
-
 // Struct to generate the output JSON for Network Status and Metrics
 type networkStatusMetrics struct {
 	IfName              string // Interface's name
@@ -45,11 +44,6 @@ type networkStatusMetrics struct {
 	RxACLRateLimitDrops uint64 // For all rate limited rules
 }
 
-const (
-	patchEnvelopesContextKey middlewareKeys = iota
-	appUUIDContextKey
-)
-
 func (msrv *Msrv) handleNetwork() func(http.ResponseWriter, *http.Request) {
 	return func(w http.ResponseWriter, r *http.Request) {
 		msrv.Log.Tracef("networkHandler.ServeHTTP")
@@ -737,38 +731,6 @@ func (msrv *Msrv) handleAppInstanceDiscovery() func(http.ResponseWriter, *http.R
 	}
 }
 
-// withPatchEnvelopesByIP is a middleware for Patch Envelopes which adds
-// to a context patchEnvelope variable containing available patch envelopes
-// for given IP address (it gets resolved to app instance UUID)
-// in case there is no patch envelopes available it returns StatusNoContent
-func (msrv *Msrv) withPatchEnvelopesByIP() func(http.Handler) http.Handler {
-	return func(next http.Handler) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			remoteIP := net.ParseIP(strings.Split(r.RemoteAddr, ":")[0])
-			anStatus := msrv.lookupAppNetworkStatusByAppIP(remoteIP)
-			if anStatus == nil {
-				w.WriteHeader(http.StatusNoContent)
-				msrv.Log.Errorf("No AppNetworkStatus for %s",
-					remoteIP.String())
-				return
-			}
-
-			appUUID := anStatus.UUIDandVersion.UUID
-
-			accessablePe := msrv.PatchEnvelopes.Get(appUUID.String())
-			if len(accessablePe.Envelopes) == 0 {
-				sendError(w, http.StatusNotFound, fmt.Sprintf("No envelopes for %s", appUUID.String()))
-			}
-
-			ctx := context.WithValue(r.Context(), patchEnvelopesContextKey, accessablePe)
-			ctx = context.WithValue(ctx, appUUIDContextKey, appUUID.String())
-
-			r = r.WithContext(ctx)
-			next.ServeHTTP(w, r)
-		})
-	}
-}
-
 // handleActivateCredentialGet handles get request of the activate-credential exchange,
 // it returns a json containing the EK, AIK public keys and AIK name.
 func (msrv *Msrv) handleActivateCredentialGet() func(http.ResponseWriter, *http.Request) {
@@ -918,3 +880,17 @@ func (msrv *Msrv) handleNetworkStatusMetrics() func(http.ResponseWriter, *http.R
 		w.Write(resp)
 	}
 }
+
+// reverseProxy is a handler which proxies request to target URL
+func (msrv *Msrv) reverseProxy(target *url.URL) func(http.ResponseWriter, *http.Request) {
+	proxy := httputil.NewSingleHostReverseProxy(target)
+	proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) {
+		msrv.Log.Errorf("httputil proxy error: %v", err)
+		rw.WriteHeader(http.StatusBadGateway)
+	}
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		r.Host = target.Host
+		proxy.ServeHTTP(w, r)
+	}
+}
diff --git a/pkg/pillar/cmd/msrv/middlewares.go b/pkg/pillar/cmd/msrv/middlewares.go
new file mode 100644
index 00000000000..b67630cb0db
--- /dev/null
+++ b/pkg/pillar/cmd/msrv/middlewares.go
@@ -0,0 +1,117 @@
+// Copyright (c) 2025 Zededa, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package msrv
+
+import (
+	"context"
+	"fmt"
+	"golang.org/x/time/rate"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+)
+
+type middlewareKeys int
+
+const (
+	patchEnvelopesContextKey middlewareKeys = iota
+	appUUIDContextKey
+)
+
+// withPatchEnvelopesByIP is a middleware for Patch Envelopes which adds
+// to a context patchEnvelope variable containing available patch envelopes
+// for given IP address (it gets resolved to app instance UUID)
+// in case there is no patch envelopes available it returns StatusNoContent
+func (msrv *Msrv) withPatchEnvelopesByIP() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			remoteIP := net.ParseIP(strings.Split(r.RemoteAddr, ":")[0])
+			anStatus := msrv.lookupAppNetworkStatusByAppIP(remoteIP)
+			if anStatus == nil {
+				w.WriteHeader(http.StatusNoContent)
+				msrv.Log.Errorf("No AppNetworkStatus for %s",
+					remoteIP.String())
+				return
+			}
+
+			appUUID := anStatus.UUIDandVersion.UUID
+
+			accessablePe := msrv.PatchEnvelopes.Get(appUUID.String())
+			if len(accessablePe.Envelopes) == 0 {
+				sendError(w, http.StatusNotFound, fmt.Sprintf("No envelopes for %s", appUUID.String()))
+			}
+
+			ctx := context.WithValue(r.Context(), patchEnvelopesContextKey, accessablePe)
+			ctx = context.WithValue(ctx, appUUIDContextKey, appUUID.String())
+
+			r = r.WithContext(ctx)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+type ipEntry struct {
+	limiter  *rate.Limiter
+	lastSeen time.Time
+}
+
+// withRateLimiterPerIP is a middleware that limits incoming HTTP requests per IP address.
+// It enforces a rate limit with the given RPS and burst size, and automatically removes
+// inactive IPs after the specified idle timeout.
+func withRateLimiterPerIP(rps float64, burst int, idleTimeout time.Duration) func(http.Handler) http.Handler {
+	var (
+		mu       sync.Mutex
+		visitors = make(map[string]*ipEntry)
+	)
+
+	// goroutine to cleanup idle IPs
+	go func() {
+		for {
+			time.Sleep(time.Minute)
+			mu.Lock()
+			now := time.Now()
+			for ip, v := range visitors {
+				if now.Sub(v.lastSeen) > idleTimeout {
+					delete(visitors, ip)
+				}
+			}
+			mu.Unlock()
+		}
+	}()
+
+	getLimiter := func(ip string) *rate.Limiter {
+		mu.Lock()
+		defer mu.Unlock()
+
+		v, exists := visitors[ip]
+		if !exists {
+			limiter := rate.NewLimiter(rate.Limit(rps), burst)
+			visitors[ip] = &ipEntry{limiter: limiter, lastSeen: time.Now()}
+			return limiter
+		}
+
+		v.lastSeen = time.Now()
+		return v.limiter
+	}
+
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ip, _, err := net.SplitHostPort(r.RemoteAddr)
+			if err != nil {
+				http.Error(w, "Invalid IP address", http.StatusInternalServerError)
+				return
+			}
+
+			limiter := getLimiter(ip)
+			if !limiter.Allow() {
+				http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
+				return
+			}
+
+			next.ServeHTTP(w, r)
+		})
+	}
+}
diff --git a/pkg/pillar/cmd/msrv/msrv.go b/pkg/pillar/cmd/msrv/msrv.go
index 3c4288fc4dc..80bb1dcf91b 100644
--- a/pkg/pillar/cmd/msrv/msrv.go
+++ b/pkg/pillar/cmd/msrv/msrv.go
@@ -14,6 +14,7 @@ import (
 	"context"
 	"encoding/gob"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-chi/chi/v5"
@@ -63,6 +64,16 @@ const DiagMaxSize = 65535
 // MetaDataServerIP is IP of meta data server
 const MetaDataServerIP = "169.254.169.254"
 
+// We forward /metrics endpoint to this URL
+// we assume that NodeExporter will be serving on default port 9100
+const (
+	NodeExporterMetricsURL            = "http://localhost:9100"
+	MetricsRouteRequestPerSecondLimit = 1
+	MetricsRouteBurstLimit            = 10
+	// Clean up idle IP addresses
+	MetricsRouteIdleTimeout = 4 * time.Minute
+)
+
 // Msrv struct contains all PubSubs which are needed to compose REST APIs
 // for App Instances
 type Msrv struct {
@@ -712,6 +723,12 @@ func (msrv *Msrv) MakeMetadataHandler() http.Handler {
 		})
 	})
 
+	target, _ := url.Parse(NodeExporterMetricsURL)
+	r.Route("/metrics", func(r chi.Router) {
+		r.Use(withRateLimiterPerIP(MetricsRouteRequestPerSecondLimit, MetricsRouteBurstLimit, MetricsRouteIdleTimeout))
+		r.HandleFunc("/", msrv.reverseProxy(target))
+	})
+
 	r.Get("/eve/app-custom-blobs", msrv.handleAppCustomBlobs())
 
 	r.Get("/openstack", msrv.handleOpenStack())
diff --git a/pkg/pillar/cmd/msrv/msrv_test.go b/pkg/pillar/cmd/msrv/msrv_test.go
index 036ea3e7bc1..36adc6dbf0c 100644
--- a/pkg/pillar/cmd/msrv/msrv_test.go
+++ b/pkg/pillar/cmd/msrv/msrv_test.go
@@ -10,6 +10,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"sync/atomic"
 	"testing"
 	"time"
 
@@ -480,3 +481,76 @@ func TestHandleAppInstanceDiscovery(t *testing.T) {
 	handler.ServeHTTP(descResp, descReq)
 	g.Expect(descResp.Code).To(gomega.Equal(http.StatusForbidden))
 }
+
+func TestReverseProxy(t *testing.T) {
+	t.Parallel()
+	g := gomega.NewGomegaWithT(t)
+
+	logger := logrus.StandardLogger()
+	log := base.NewSourceLogObject(logger, "pubsub", 1234)
+	ps := pubsub.New(pubsub.NewMemoryDriver(), logger, log)
+
+	srv := &msrv.Msrv{
+		Log:    log,
+		PubSub: ps,
+		Logger: logger,
+	}
+
+	dir, err := os.MkdirTemp("/tmp", "msrv_test")
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+	defer os.RemoveAll(dir)
+
+	err = srv.Init(dir, true)
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+
+	err = srv.Activate()
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+
+	handler := srv.MakeMetadataHandler()
+
+	var count int32
+	backend := &http.Server{
+		Addr: "localhost:9100",
+		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			atomic.AddInt32(&count, 1)
+			w.WriteHeader(http.StatusOK)
+			w.Write([]byte("proxied response"))
+		}),
+	}
+
+	ln, err := net.Listen("tcp", "localhost:9100")
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+
+	go func() {
+		_ = backend.Serve(ln)
+	}()
+	defer func() {
+		_ = backend.Close()
+	}()
+
+	makeReq := func(ip string) *httptest.ResponseRecorder {
+		req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+		req.RemoteAddr = ip + ":12345"
+		rr := httptest.NewRecorder()
+		handler.ServeHTTP(rr, req)
+		return rr
+	}
+
+	ip1 := "10.0.0.1"
+	ip2 := "10.0.0.2"
+
+	// 1st request from each IP should succeed
+	rr := makeReq(ip1)
+	g.Expect(rr.Code).To(gomega.Equal(http.StatusOK))
+	// Burst of requests, which should be rate limited
+	for range 10 {
+		_ = makeReq(ip1)
+	}
+	// request after burst should fail
+	rr = makeReq(ip1)
+	g.Expect(rr.Code).To(gomega.Equal(http.StatusTooManyRequests))
+
+	rr = makeReq(ip2)
+	g.Expect(rr.Code).To(gomega.Equal(http.StatusOK))
+
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a new /metrics endpoint that reverse proxies requests to a NodeExporter instance running on localhost:9100 and enforces per-IP rate limiting. Key changes include:
- Proxying /metrics requests to NodeExporter via a newly added reverse proxy handler.
- Introducing rate limiting middleware that allows one request per second (with a 10-request burst) per IP.
- Adding tests to validate the reverse proxy functionality and rate limiting behavior.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File                                   | Description                                                       |
| -------------------------------------- | ----------------------------------------------------------------- |
| pkg/pillar/cmd/msrv/msrv_test.go        | Added tests for reverse proxy and rate limiting behavior          |
| pkg/pillar/cmd/msrv/msrv.go             | Configured the /metrics route and integrated the reverse proxy      |
| pkg/pillar/cmd/msrv/middlewares.go       | Added middleware for per-IP rate limiting                            |
| pkg/pillar/cmd/msrv/handlers.go          | Added reverseProxy handler implementation for /metrics endpoint    |




state: COMMENTED
author_association: NONE
submitted_at: 2025-04-25T16:30:20Z
user: login: OhmSpectator
body: Later, when the base PR is merged, I'll ask you to add a short description of the functionality to the corresponding `.md` file. It should make clear:
1) for a user - how to access the metrics, and
2) for a developer - how it's integrated.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T17:10:44Z
user: login: OhmSpectator
body: I went through some not-so-easy web-backend patterns - felt a bit like JavaScript - but I think I get how it works now. The middleware setup seems fine, even if it twists my brain a bit. I trust the tests prove it works.

Would still appreciate a note for testers on how to manually hit /metrics from an AppInstance to confirm rate-limiting in practice.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T17:37:03Z
user: login: OhmSpectator
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-26T19:01:42Z
user: login: milan-zededa
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T07:52:26Z
user: login: OhmSpectator
body: Also we should put a referecne into PROMETHEUS-METRICS.md, when #4774 is merged.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T11:58:26Z
user: login: OhmSpectator
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T18:54:09Z

Review comments:
diff_hunk: @@ -712,6 +723,12 @@ func (msrv *Msrv) MakeMetadataHandler() http.Handler {
 		})
 	})
 
+	target, _ := url.Parse(NodeExporterMetricsURL)
path: pkg/pillar/cmd/msrv/msrv.go
user: login: Copilot
body: Ignoring the error returned by url.Parse may hide potential misconfigurations. Although NodeExporterMetricsURL is a constant, it would be more robust to handle the error.
```suggestion
	target, err := url.Parse(NodeExporterMetricsURL)
	if err != nil {
		msrv.Log.Fatalf("Invalid NodeExporterMetricsURL: %v", err)
	}
```
created_at: 2025-04-25T16:30:19Z
author_association: NONE
diff_hunk: @@ -918,3 +880,17 @@ func (msrv *Msrv) handleNetworkStatusMetrics() func(http.ResponseWriter, *http.R
 		w.Write(resp)
 	}
 }
+
+// reverseProxy is a handler which proxies request to target URL
+func (msrv *Msrv) reverseProxy(target *url.URL) func(http.ResponseWriter, *http.Request) {
+	proxy := httputil.NewSingleHostReverseProxy(target)
+	proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) {
+		msrv.Log.Errorf("httputil proxy error: %v", err)
+		rw.WriteHeader(http.StatusBadGateway)
+	}
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		r.Host = target.Host
path: pkg/pillar/cmd/msrv/handlers.go
user: login: Copilot
body: Directly setting r.Host may interfere with the standard reverse proxy behavior. Consider configuring the proxy's Director function to adjust the request instead.
```suggestion
	// Configure the Director function to modify the request
	proxy.Director = func(req *http.Request) {
		req.Host = target.Host
		req.URL.Scheme = target.Scheme
		req.URL.Host = target.Host
	}

	return func(w http.ResponseWriter, r *http.Request) {
```
created_at: 2025-04-25T16:30:20Z
author_association: NONE
diff_hunk: @@ -419,3 +419,46 @@ curl -X GET http://169.254.169.254/eve/v1/patch/download/699fbdb2-e455-448f-84f5
 
 %base64-encoded file contents%
 ```
+
+### Prometheus '/metrics' endpoint
+
+The /metrics endpoint provides access to system-level metrics from the EVE host where your EdgeApp is running.
+These metrics are sourced directly from a Node Exporter instance, expected to be available on localhost:9001 within the EVE host.
+
+When a client inside the EdgeApp makes a request to '/metrics', the EdgeApp transparently forwards the request to the Node Exporter
+and returns the response. The metrics are in Prometheus exposition format, making them compatible with Prometheus scraping and monitoring tools.
+
+>About Prometheus:
+[Prometheus](https://prometheus.io/) is an open-source systems monitoring and alerting toolkit. It collects and stores metrics as time series data, allowing users to query, visualize, and create alerts based on those metrics.
+
+#### Key Details
+
+- Target: Forwards all traffic internally to localhost:9001 on the EVE host.
path: docs/ECO-METADATA.md
user: login: OhmSpectator
body: 9100?
created_at: 2025-04-28T11:56:29Z
author_association: MEMBER
diff_hunk: @@ -419,3 +419,46 @@ curl -X GET http://169.254.169.254/eve/v1/patch/download/699fbdb2-e455-448f-84f5
 
 %base64-encoded file contents%
 ```
+
+### Prometheus '/metrics' endpoint
+
+The /metrics endpoint provides access to system-level metrics from the EVE host where your EdgeApp is running.
+These metrics are sourced directly from a Node Exporter instance, expected to be available on localhost:9001 within the EVE host.
path: docs/ECO-METADATA.md
user: login: OhmSpectator
body: 9100
created_at: 2025-04-28T11:56:46Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-24T12:08:58Z
body: ## [Codecov](https://app.codecov.io/gh/lf-edge/eve/pull/4772?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lf-edge) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 21.07%. Comparing base [(`a212b64`)](https://app.codecov.io/gh/lf-edge/eve/commit/a212b6454cf07cf094e8b008056990cb9b6be53b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lf-edge) to head [(`294c3d4`)](https://app.codecov.io/gh/lf-edge/eve/commit/294c3d4b919721108e88a2448eeb349e3769c117?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lf-edge).
> Report is 69 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #4772   +/-   ##
=======================================
  Coverage   21.07%   21.07%           
=======================================
  Files           8        8           
  Lines        1167     1167           
=======================================
  Hits          246      246           
  Misses        861      861           
  Partials       60       60           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/lf-edge/eve/pull/4772?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lf-edge).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lf-edge).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: uncleDecart
created_at: 2025-04-28T11:38:49Z
body: @OhmSpectator I've added the docs, thanks for pointing that one out, it's important 

author_association: MEMBER
user: login: OhmSpectator
created_at: 2025-04-28T11:59:25Z
body: Yetus is also complaining bout blank lines and other stuff in the MD file. 
author_association: MEMBER
user: login: uncleDecart
created_at: 2025-04-28T13:57:12Z
body: Yetus complains on zfs, rest is fixed 
author_association: MEMBER
user: login: OhmSpectator
created_at: 2025-04-28T15:59:38Z
body: Rebased on a new master with a fixed GH build
author_association: MEMBER