feat: add metrics system, move webhook feature to dedicated module (#372)

This commit is contained in:
Julien Neuhart
2021-10-19 18:35:24 +02:00
committed by GitHub
parent b839069af1
commit d4c47cecf2
39 changed files with 3359 additions and 1309 deletions

View File

@@ -0,0 +1,3 @@
// Package prometheus provides a module which collects metrics and exposes them
// via an HTTP route.
package prometheus

View File

@@ -0,0 +1,189 @@
package prometheus
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
flag "github.com/spf13/pflag"
)
func init() {
gotenberg.MustRegisterModule(Prometheus{})
}
// Prometheus is a module which collects metrics and exposes them via an HTTP
// route.
type Prometheus struct {
namespace string
interval time.Duration
disableRouteLogging bool
disableCollect bool
metrics []gotenberg.Metric
registry *prometheus.Registry
}
// Descriptor returns a Prometheus's module descriptor.
func (Prometheus) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "prometheus",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("prometheus", flag.ExitOnError)
fs.String("prometheus-namespace", "gotenberg", "Set the namespace of modules' metrics")
fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics")
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics")
return fs
}(),
New: func() gotenberg.Module { return new(Prometheus) },
}
}
// Provision sets the modules properties.
func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.namespace = flags.MustString("prometheus-namespace")
mod.interval = flags.MustDuration("prometheus-collect-interval")
mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging")
mod.disableCollect = flags.MustBool("prometheus-disable-collect")
if mod.disableCollect {
// Exit early.
return nil
}
// Get metrics from modules.
mods, err := ctx.Modules(new(gotenberg.MetricsProvider))
if err != nil {
return fmt.Errorf("get metrics providers: %w", err)
}
metricsProviders := make([]gotenberg.MetricsProvider, len(mods))
for i, metricsProvider := range mods {
metricsProviders[i] = metricsProvider.(gotenberg.MetricsProvider)
}
for _, metricsProvider := range metricsProviders {
metrics, err := metricsProvider.Metrics()
if err != nil {
return fmt.Errorf("get metrics: %w", err)
}
mod.metrics = append(mod.metrics, metrics...)
}
mod.registry = prometheus.NewRegistry()
return nil
}
// Validate validates the module properties.
func (mod Prometheus) Validate() error {
if mod.disableCollect {
// Exit early.
return nil
}
if mod.namespace == "" {
return errors.New("namespace must not be empty")
}
metricsMap := make(map[string]string, len(mod.metrics))
for _, metric := range mod.metrics {
if metric.Name == "" {
return errors.New("metric name cannot be empty")
}
if metric.Read == nil {
return fmt.Errorf("metric '%s' has nil read method", metric.Name)
}
if _, ok := metricsMap[metric.Name]; ok {
return fmt.Errorf("metric '%s' is already registered", metric.Name)
}
metricsMap[metric.Name] = metric.Name
}
return nil
}
// Start starts the collect.
func (mod Prometheus) Start() error {
if mod.disableCollect {
// Exit early.
return nil
}
for _, metric := range mod.metrics {
gauge := prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: mod.namespace,
Name: metric.Name,
Help: metric.Description,
},
)
mod.registry.MustRegister(gauge)
go func(gauge prometheus.Gauge, metric gotenberg.Metric) {
for {
gauge.Set(metric.Read())
time.Sleep(mod.interval)
}
}(gauge, metric)
}
return nil
}
// StartupMessage returns a custom startup message.
func (mod Prometheus) StartupMessage() string {
if mod.disableCollect {
return "application not started (collect disabled by user)"
}
return "collecting metrics"
}
// Stop does nothing.
func (mod Prometheus) Stop(_ context.Context) error {
return nil
}
// Routes returns the HTTP route.
func (mod Prometheus) Routes() ([]api.Route, error) {
if mod.disableCollect {
return nil, nil
}
return []api.Route{
{
Method: http.MethodGet,
Path: "/prometheus/metrics",
DisableLogging: mod.disableRouteLogging,
Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),
),
},
}, nil
}
// Interface guards.
var (
_ gotenberg.Module = (*Prometheus)(nil)
_ gotenberg.Provisioner = (*Prometheus)(nil)
_ gotenberg.Validator = (*Prometheus)(nil)
_ gotenberg.App = (*Prometheus)(nil)
_ api.Router = (*Prometheus)(nil)
)

View File

@@ -0,0 +1,360 @@
package prometheus
import (
"errors"
"reflect"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/prometheus/client_golang/prometheus"
)
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoValidator struct {
ProtoModule
validate func() error
}
func (mod ProtoValidator) Validate() error {
return mod.validate()
}
type ProtoMetricsProvider struct {
ProtoValidator
metrics func() ([]gotenberg.Metric, error)
}
func (mod ProtoMetricsProvider) Metrics() ([]gotenberg.Metric, error) {
return mod.metrics()
}
func TestPrometheus_Descriptor(t *testing.T) {
descriptor := Prometheus{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Prometheus))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPrometheus_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectMetrics []gotenberg.Metric
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
fs := new(Prometheus).Descriptor().FlagSet
err := fs.Parse([]string{"--prometheus-disable-collect=true"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectMetrics: []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
},
},
} {
mod := new(Prometheus)
err := mod.Provision(tc.ctx)
if !reflect.DeepEqual(mod.metrics, tc.expectMetrics) {
t.Errorf("test %d: expected %+v, but got: %+v", i, tc.expectMetrics, mod.metrics)
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestPrometheus_Validate(t *testing.T) {
for i, tc := range []struct {
namespace string
metrics []gotenberg.Metric
disableCollect bool
expectErr bool
}{
{
disableCollect: true,
},
{
namespace: "",
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "",
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "bar",
Read: func() float64 {
return 0
},
},
},
},
} {
mod := Prometheus{
namespace: tc.namespace,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
}
err := mod.Validate()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestPrometheus_Start(t *testing.T) {
for i, tc := range []struct {
metrics []gotenberg.Metric
disableCollect bool
}{
{
disableCollect: true,
},
{
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
},
} {
mod := Prometheus{
namespace: "foo",
interval: time.Duration(1) * time.Second,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
err := mod.Start()
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestPrometheus_StartupMessage(t *testing.T) {
for i, tc := range []struct {
disableCollect bool
expectMessage string
}{
{
expectMessage: "application not started (collect disabled by user)",
disableCollect: true,
},
{
expectMessage: "collecting metrics",
},
} {
mod := Prometheus{
disableCollect: tc.disableCollect,
}
actual := mod.StartupMessage()
if actual != tc.expectMessage {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectMessage, actual)
}
}
}
func TestPrometheus_Stop(t *testing.T) {
err := Prometheus{}.Stop(nil)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPrometheus_Routes(t *testing.T) {
for i, tc := range []struct {
expectRoutes int
disableCollect bool
}{
{
disableCollect: true,
},
{
expectRoutes: 1,
},
} {
mod := Prometheus{
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
}
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ gotenberg.MetricsProvider = (*ProtoMetricsProvider)(nil)
_ gotenberg.Module = (*ProtoMetricsProvider)(nil)
_ gotenberg.Validator = (*ProtoMetricsProvider)(nil)
)