diff --git a/api/endpoints.go b/api/endpoints.go index 1c34d30..8ae8860 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -436,6 +436,9 @@ func (e *Endpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Add response headers to request struct so that the endpoint can work with them. + apiRequest.ResponseHeader = w.Header() + // Execute action function and get response data var responseData []byte var err error diff --git a/api/endpoints_debug.go b/api/endpoints_debug.go index 3baf1a2..aed729f 100644 --- a/api/endpoints_debug.go +++ b/api/endpoints_debug.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/safing/portbase/info" "github.com/safing/portbase/utils/debug" ) @@ -46,6 +47,7 @@ func registerDebugEndpoints() error { if err := RegisterEndpoint(Endpoint{ Path: "debug/cpu", + MimeType: "application/octet-stream", Read: PermitAnyone, DataFunc: handleCPUProfile, Name: "Get CPU Profile", @@ -67,6 +69,7 @@ You can easily view this data in your browser with this command (with Go install if err := RegisterEndpoint(Endpoint{ Path: "debug/heap", + MimeType: "application/octet-stream", Read: PermitAnyone, DataFunc: handleHeapProfile, Name: "Get Heap Profile", @@ -81,6 +84,7 @@ You can easily view this data in your browser with this command (with Go install if err := RegisterEndpoint(Endpoint{ Path: "debug/allocs", + MimeType: "application/octet-stream", Read: PermitAnyone, DataFunc: handleAllocsProfile, Name: "Get Allocs Profile", @@ -154,6 +158,12 @@ func handleCPUProfile(ar *Request) (data []byte, err error) { duration = parsedDuration } + // Indicate download and filename. + ar.ResponseHeader.Set( + "Content-Disposition", + fmt.Sprintf(`attachment; filename="portmaster-cpu-profile_v%s.pprof"`, info.Version()), + ) + // Start CPU profiling. buf := new(bytes.Buffer) if err := pprof.StartCPUProfile(buf); err != nil { @@ -175,6 +185,12 @@ func handleCPUProfile(ar *Request) (data []byte, err error) { // handleHeapProfile returns the Heap profile. func handleHeapProfile(ar *Request) (data []byte, err error) { + // Indicate download and filename. + ar.ResponseHeader.Set( + "Content-Disposition", + fmt.Sprintf(`attachment; filename="portmaster-memory-heap-profile_v%s.pprof"`, info.Version()), + ) + buf := new(bytes.Buffer) if err := pprof.Lookup("heap").WriteTo(buf, 0); err != nil { return nil, fmt.Errorf("failed to write heap profile: %w", err) @@ -184,6 +200,12 @@ func handleHeapProfile(ar *Request) (data []byte, err error) { // handleAllocsProfile returns the Allocs profile. func handleAllocsProfile(ar *Request) (data []byte, err error) { + // Indicate download and filename. + ar.ResponseHeader.Set( + "Content-Disposition", + fmt.Sprintf(`attachment; filename="portmaster-memory-allocs-profile_v%s.pprof"`, info.Version()), + ) + buf := new(bytes.Buffer) if err := pprof.Lookup("allocs").WriteTo(buf, 0); err != nil { return nil, fmt.Errorf("failed to write allocs profile: %w", err) diff --git a/api/request.go b/api/request.go index 8a4d70b..9969ce3 100644 --- a/api/request.go +++ b/api/request.go @@ -26,6 +26,9 @@ type Request struct { // AuthToken is the request-side authentication token assigned. AuthToken *AuthToken + // ResponseHeader holds the response header. + ResponseHeader http.Header + // HandlerCache can be used by handlers to cache data between handlers within a request. HandlerCache interface{} } diff --git a/metrics/api.go b/metrics/api.go index ccf4051..4597882 100644 --- a/metrics/api.go +++ b/metrics/api.go @@ -3,7 +3,6 @@ package metrics import ( "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -17,20 +16,41 @@ import ( func registerAPI() error { api.RegisterHandler("/metrics", &metricsAPI{}) - return api.RegisterEndpoint(api.Endpoint{ - Path: "metrics/list", - Read: api.PermitAnyone, - MimeType: api.MimeTypeJSON, - BelongsTo: module, - DataFunc: func(*api.Request) ([]byte, error) { - registryLock.RLock() - defer registryLock.RUnlock() - - return json.Marshal(registry) - }, + if err := api.RegisterEndpoint(api.Endpoint{ Name: "Export Registered Metrics", Description: "List all registered metrics with their metadata.", - }) + Path: "metrics/list", + Read: api.Dynamic, + BelongsTo: module, + StructFunc: func(ar *api.Request) (any, error) { + return ExportMetrics(ar.AuthToken.Read), nil + }, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Name: "Export Metric Values", + Description: "List all exportable metric values.", + Path: "metrics/values", + Read: api.Dynamic, + Parameters: []api.Parameter{{ + Method: http.MethodGet, + Field: "internal-only", + Description: "Specify to only return metrics with an alternative internal ID.", + }}, + BelongsTo: module, + StructFunc: func(ar *api.Request) (any, error) { + return ExportValues( + ar.AuthToken.Read, + ar.Request.URL.Query().Has("internal-only"), + ), nil + }, + }); err != nil { + return err + } + + return nil } type metricsAPI struct{} diff --git a/metrics/metric.go b/metrics/metric.go index e72e831..f3cfd58 100644 --- a/metrics/metric.go +++ b/metrics/metric.go @@ -43,6 +43,10 @@ type Options struct { // Name defines an optional human readable name for the metric. Name string + // InternalID specifies an alternative internal ID that will be used when + // exposing the metric via the API in a structured format. + InternalID string + // AlertLimit defines an upper limit that triggers an alert. AlertLimit float64 diff --git a/metrics/metric_counter.go b/metrics/metric_counter.go index d20ad6c..90cf7c6 100644 --- a/metrics/metric_counter.go +++ b/metrics/metric_counter.go @@ -42,3 +42,8 @@ func NewCounter(id string, labels map[string]string, opts *Options) (*Counter, e return m, nil } + +// CurrentValue returns the current counter value. +func (c *Counter) CurrentValue() uint64 { + return c.Get() +} diff --git a/metrics/metric_counter_fetching.go b/metrics/metric_counter_fetching.go index d8e4b7b..423d74a 100644 --- a/metrics/metric_counter_fetching.go +++ b/metrics/metric_counter_fetching.go @@ -50,6 +50,11 @@ func NewFetchingCounter(id string, labels map[string]string, fn func() uint64, o return m, nil } +// CurrentValue returns the current counter value. +func (fc *FetchingCounter) CurrentValue() uint64 { + return fc.fetchCnt() +} + // WritePrometheus writes the metric in the prometheus format to the given writer. func (fc *FetchingCounter) WritePrometheus(w io.Writer) { fc.counter.Set(fc.fetchCnt()) diff --git a/metrics/metric_export.go b/metrics/metric_export.go new file mode 100644 index 0000000..2dcd944 --- /dev/null +++ b/metrics/metric_export.go @@ -0,0 +1,89 @@ +package metrics + +import ( + "github.com/safing/portbase/api" +) + +// UIntMetric is an interface for special functions of uint metrics. +type UIntMetric interface { + CurrentValue() uint64 +} + +// FloatMetric is an interface for special functions of float metrics. +type FloatMetric interface { + CurrentValue() float64 +} + +// MetricExport is used to export a metric and its current value. +type MetricExport struct { + Metric + CurrentValue any +} + +// ExportMetrics exports all registered metrics. +func ExportMetrics(requestPermission api.Permission) []*MetricExport { + registryLock.RLock() + defer registryLock.RUnlock() + + export := make([]*MetricExport, 0, len(registry)) + for _, metric := range registry { + // Check permission. + if requestPermission < metric.Opts().Permission { + continue + } + + // Add metric with current value. + export = append(export, &MetricExport{ + Metric: metric, + CurrentValue: getCurrentValue(metric), + }) + } + + return export +} + +// ExportValues exports the values of all supported metrics. +func ExportValues(requestPermission api.Permission, internalOnly bool) map[string]any { + registryLock.RLock() + defer registryLock.RUnlock() + + export := make(map[string]any, len(registry)) + for _, metric := range registry { + // Check permission. + if requestPermission < metric.Opts().Permission { + continue + } + + // Get Value. + v := getCurrentValue(metric) + if v == nil { + continue + } + + // Get ID. + var id string + switch { + case metric.Opts().InternalID != "": + id = metric.Opts().InternalID + case internalOnly: + continue + default: + id = metric.LabeledID() + } + + // Add to export + export[id] = v + } + + return export +} + +func getCurrentValue(metric Metric) any { + if m, ok := metric.(UIntMetric); ok { + return m.CurrentValue() + } + if m, ok := metric.(FloatMetric); ok { + return m.CurrentValue() + } + return nil +} diff --git a/metrics/metric_gauge.go b/metrics/metric_gauge.go index 06628a8..6e8ea6e 100644 --- a/metrics/metric_gauge.go +++ b/metrics/metric_gauge.go @@ -39,3 +39,8 @@ func NewGauge(id string, labels map[string]string, fn func() float64, opts *Opti return m, nil } + +// CurrentValue returns the current gauge value. +func (g *Gauge) CurrentValue() float64 { + return g.Get() +} diff --git a/metrics/module.go b/metrics/module.go index 3abec67..095dee8 100644 --- a/metrics/module.go +++ b/metrics/module.go @@ -92,6 +92,10 @@ func register(m Metric) error { if m.LabeledID() == registeredMetric.LabeledID() { return ErrAlreadyRegistered } + if m.Opts().InternalID != "" && + m.Opts().InternalID == registeredMetric.Opts().InternalID { + return fmt.Errorf("%w with this internal ID", ErrAlreadyRegistered) + } } // Add new metric to registry and sort it.