Skip to content

Commit

Permalink
feat!: OpenTelemetry metrics (#13265)
Browse files Browse the repository at this point in the history
Signed-off-by: Alan Clucas <alan@clucas.org>
  • Loading branch information
Joibel authored Aug 15, 2024
1 parent 282a0d3 commit 9756bab
Show file tree
Hide file tree
Showing 53 changed files with 2,468 additions and 1,497 deletions.
3 changes: 3 additions & 0 deletions .spelling
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ CRDs
CloudSQL
ClusterRoleBinding
ClusterRoles
ClusterWorkflowTemplate
Codespaces
ConfigMap
ConfigMaps
Expand Down Expand Up @@ -92,6 +93,7 @@ OAuth
OAuth2
Okta
OpenAPI
OpenTelemetry
PDBs
PProf
PVCs
Expand Down Expand Up @@ -247,4 +249,5 @@ webHDFS
webhook
webhooks
workflow-controller-configmap
workqueue
yaml
18 changes: 9 additions & 9 deletions cmd/workflow-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,17 @@ func NewRootCommand() *cobra.Command {
if err != nil {
return err
}
// start a controller on instances of our custom resource
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

version := argo.GetVersion()
config = restclient.AddUserAgent(config, fmt.Sprintf("argo-workflows/%s argo-controller", version.Version))
config.Burst = burst
config.QPS = qps

logs.AddK8SLogTransportWrapper(config)
metrics.AddMetricsTransportWrapper(config)
metrics.AddMetricsTransportWrapper(ctx, config)

namespace, _, err := clientConfig.Namespace()
if err != nil {
Expand All @@ -106,10 +110,6 @@ func NewRootCommand() *cobra.Command {
managedNamespace = namespace
}

// start a controller on instances of our custom resource
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

wfController, err := controller.NewWorkflowController(ctx, config, kubeclientset, wfclientset, namespace, managedNamespace, executorImage, executorImagePullPolicy, logFormat, configMap, executorPlugins)
errors.CheckError(err)

Expand All @@ -118,7 +118,7 @@ func NewRootCommand() *cobra.Command {
log.Info("Leader election is turned off. Running in single-instance mode")
log.WithField("id", "single-instance").Info("starting leading")
go wfController.Run(ctx, workflowWorkers, workflowTTLWorkers, podCleanupWorkers, cronWorkflowWorkers)
go wfController.RunMetricsServer(ctx, false)
go wfController.RunPrometheusServer(ctx, false)
} else {
nodeID, ok := os.LookupEnv("LEADER_ELECTION_IDENTITY")
if !ok {
Expand All @@ -133,7 +133,7 @@ func NewRootCommand() *cobra.Command {
// for controlling the dummy metrics server
dummyCtx, dummyCancel := context.WithCancel(context.Background())
defer dummyCancel()
go wfController.RunMetricsServer(dummyCtx, true)
go wfController.RunPrometheusServer(dummyCtx, true)

go leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
Lock: &resourcelock.LeaseLock{
Expand All @@ -148,12 +148,12 @@ func NewRootCommand() *cobra.Command {
OnStartedLeading: func(ctx context.Context) {
dummyCancel()
go wfController.Run(ctx, workflowWorkers, workflowTTLWorkers, podCleanupWorkers, cronWorkflowWorkers)
go wfController.RunMetricsServer(ctx, false)
go wfController.RunPrometheusServer(ctx, false)
},
OnStoppedLeading: func() {
log.WithField("id", nodeID).Info("stopped leading")
cancel()
go wfController.RunMetricsServer(dummyCtx, true)
go wfController.RunPrometheusServer(dummyCtx, true)
},
OnNewLeader: func(identity string) {
log.WithField("leader", identity).Info("new leader")
Expand Down
360 changes: 260 additions & 100 deletions docs/metrics.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,40 @@ Previously it was `--basehref` (no dash in between) and `ARGO_BASEHREF` (no unde
`ALLOWED_LINK_PROTOCOL` and `BASE_HREF` have been removed as redundant.
Use `ARGO_ALLOWED_LINK_PROTOCOL` and `ARGO_BASE_HREF` instead.

### Metrics changes

You can now retrieve metrics using the OpenTelemetry Protocol using the [OpenTelemetry collector](https://opentelemetry.io/docs/collector/), and this is the recommended mechanism.

These notes explain the differences in using the Prometheus `/metrics` endpoint to scrape metrics for a minimal effort upgrade. It is not recommended you follow this guide blindly, the new metrics have been introduced because they add value, and so they should be worth collecting and using.

#### New metrics

The following are new metrics:

* `queue_duration`
* `queue_longest_running`
* `queue_retries`
* `queue_unfinished_work`

#### Renamed metrics

If you are using these metrics in your recording rules, dashboards, or alerts, you will need to update their names after the upgrade:

| Old name | New name |
|------------------------------------|------------------------------------|
| `argo_workflows_count` | `argo_workflows_gauge` |
| `argo_workflows_pods_count` | `argo_workflows_pods_gauge` |
| `argo_workflows_queue_depth_count` | `argo_workflows_queue_depth_gauge` |
| `log_messages` | `argo_workflows_log_messages` |

#### Custom metrics

Custom metric names and labels must be valid Prometheus and OpenTelemetry names now. This prevents the use of `:`, which was usable in earlier versions of workflows

Custom metrics, as defined by a workflow, could be defined as one type (say counter) in one workflow, and then as a histogram of the same name in a different workflow. This would work in 3.5 if the first usage of the metric had reached TTL and been deleted. This will no-longer work in 3.6, and custom metrics may not be redefined. It doesn't really make sense to change a metric in this way, and the OpenTelemetry SDK prevents you from doing so.

`metricsTTL` for histogram metrics is not functional as opentelemetry doesn't allow deletion of metrics. This is faked via asynchronous meters for the other metric types.

## Upgrading to v3.5

There are no known breaking changes in this release.
Expand Down
2 changes: 1 addition & 1 deletion docs/workflow-controller-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ data:
path: /metrics
# Port is the port where metrics are emitted. Default is "9090"
port: 8080
# MetricsTTL sets how often custom metrics are cleared from memory. Default is "0", metrics are never cleared
# MetricsTTL sets how often custom metrics are cleared from memory. Default is "0", metrics are never cleared. Histogram metrics are never cleared.
metricsTTL: "10m"
# IgnoreErrors is a flag that instructs prometheus to ignore metric emission errors. Default is "false"
ignoreErrors: false
Expand Down
16 changes: 12 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ require (
github.com/minio/minio-go/v7 v7.0.66
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.19.0
github.com/prometheus/client_model v0.6.0
github.com/prometheus/common v0.48.0
github.com/robfig/cron/v3 v3.0.1
github.com/sethvargo/go-limiter v0.7.2
Expand All @@ -55,6 +54,13 @@ require (
github.com/upper/db/v4 v4.7.0
github.com/valyala/fasttemplate v1.2.2
github.com/xeipuuv/gojsonschema v1.2.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0
go.opentelemetry.io/otel v1.23.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.0
go.opentelemetry.io/otel/exporters/prometheus v0.45.1
go.opentelemetry.io/otel/metric v1.23.0
go.opentelemetry.io/otel/sdk v1.23.0
go.opentelemetry.io/otel/sdk/metric v1.23.0
golang.org/x/crypto v0.24.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/oauth2 v0.16.0
Expand Down Expand Up @@ -83,6 +89,7 @@ require (
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
Expand All @@ -92,6 +99,7 @@ require (
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgio v1.0.0 // indirect
Expand All @@ -108,6 +116,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
Expand All @@ -117,9 +126,8 @@ require (
github.com/vbatts/tar-split v0.11.3 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect
go.opentelemetry.io/otel v1.22.0 // indirect
go.opentelemetry.io/otel/metric v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.22.0 // indirect
go.opentelemetry.io/otel/trace v1.23.0 // indirect
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
Expand Down
30 changes: 22 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ github.com/blushft/go-diagrams v0.0.0-20201006005127-c78c821223d9/go.mod h1:nDeX
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
Expand Down Expand Up @@ -440,6 +442,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92Bcuy
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
Expand Down Expand Up @@ -841,15 +845,25 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 h1:sv9kVfal0MK0wBMCOGr+HeJm9v803BkJxGrk2au7j08=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw=
go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y=
go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI=
go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg=
go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY=
go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8=
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0=
go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 h1:dJlCKeq+zmO5Og4kgxqPvvJrzuD/mygs1g/NYM9dAsU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0/go.mod h1:p+hpBCpLHpuUrR0lHgnHbUnbCBll1IhrcMIlycC+xYs=
go.opentelemetry.io/otel v1.23.0 h1:Df0pqjqExIywbMCMTxkAwzjLZtRf+bBKLbUcpxO2C9E=
go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.0 h1:97CpJflo7dJK4A4SLMNoP2loDEAiG0ifF6MnLhtSHUY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.0/go.mod h1:YzC+4JHcK24PylBTZ78U0XJSYbhHY0uHYNqr+OlcLCs=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1 h1:R/bW3afad6q6VGU+MFYpnEdo0stEARMCdhWu6+JI6aI=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1/go.mod h1:wnHAfKRav5Dfp4iZhyWZ7SzQfT+rDZpEpYG7To+qJ1k=
go.opentelemetry.io/otel/metric v1.23.0 h1:pazkx7ss4LFVVYSxYew7L5I6qvLXHA0Ap2pwV+9Cnpo=
go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo=
go.opentelemetry.io/otel/sdk v1.23.0 h1:0KM9Zl2esnl+WSukEmlaAEjVY5HDZANOHferLq36BPc=
go.opentelemetry.io/otel/sdk v1.23.0/go.mod h1:wUscup7byToqyKJSilEtMf34FgdCAsFpFOjXnAwFfO0=
go.opentelemetry.io/otel/sdk/metric v1.23.0 h1:u81lMvmK6GMgN4Fty7K7S6cSKOZhMKJMK2TB+KaTs0I=
go.opentelemetry.io/otel/sdk/metric v1.23.0/go.mod h1:2LUOToN/FdX6wtfpHybOnCZjoZ6ViYajJYMiJ1LKDtQ=
go.opentelemetry.io/otel/trace v1.23.0 h1:37Ik5Ib7xfYVb4V1UtnT97T1jI+AoIYkJyPkuL4iJgI=
go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 h1:+FNtrFTmVw0YZGpBGX56XDee331t6JAXeK2bcyhLOOc=
go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
Expand Down
4 changes: 2 additions & 2 deletions pkg/apis/workflow/v1alpha1/version_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ type Version struct {

var verRe = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)`)

// BrokenDown returns the major, minor and release components
// MajorMinorPatch returns the major, minor and patch components
// of the version number, or error if this is not a release
// The error path is considered "normal" in a non-release build.
func (v Version) Components() (string, string, string, error) {
func (v Version) MajorMinorPatch() (string, string, string, error) {
matches := verRe.FindStringSubmatch(v.Version)
if matches == nil || matches[1] == "0" {
return ``, ``, ``, errors.New("Not a formal release")
Expand Down
2 changes: 1 addition & 1 deletion pkg/apis/workflow/v1alpha1/workflow_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3601,7 +3601,7 @@ func (p *Prometheus) SetValueString(val string) {
}
}

func (p *Prometheus) GetDesc() string {
func (p *Prometheus) GetKey() string {
// This serves as a hash for the metric
// TODO: Make sure this is what we want to use as the hash
labels := p.GetMetricLabels()
Expand Down
4 changes: 2 additions & 2 deletions pkg/apis/workflow/v1alpha1/workflow_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -892,9 +892,9 @@ func TestPrometheus_GetDescIsStable(t *testing.T) {
Buckets: []Amount{{"10"}, {"20"}, {"30"}},
},
}
stableDesc := metric.GetDesc()
stableDesc := metric.GetKey()
for i := 0; i < 10; i++ {
if !assert.Equal(t, stableDesc, metric.GetDesc()) {
if !assert.Equal(t, stableDesc, metric.GetKey()) {
break
}
}
Expand Down
5 changes: 2 additions & 3 deletions test/e2e/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,14 @@ func (s *MetricsSuite) TestMetricsEndpoint() {
Expect().
Status(200).
Body().
Contains(`HELP argo_workflows_count`).
Contains(`HELP argo_workflows_gauge`).
Contains(`HELP argo_workflows_k8s_request_total`).
Contains(`argo_workflows_k8s_request_total{kind="leases",status_code="200",verb="Get"}`).
Contains(`argo_workflows_k8s_request_total{kind="workflowtemplates",status_code="200",verb="List"}`).
Contains(`argo_workflows_k8s_request_total{kind="workflowtemplates",status_code="200",verb="Watch"}`).
Contains(`HELP argo_workflows_pods_count`).
Contains(`HELP argo_workflows_pods_gauge`).
Contains(`HELP argo_workflows_workers_busy`).
Contains(`HELP argo_workflows_workflow_condition`).
Contains(`HELP argo_workflows_workflows_processed_count`).
Contains(`log_messages{level="info"}`).
Contains(`log_messages{level="warning"}`).
Contains(`log_messages{level="error"}`)
Expand Down
10 changes: 9 additions & 1 deletion util/help/topics.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

func root() string {
version := `latest`
if major, minor, _, err := argo.GetVersion().Components(); err == nil {
if major, minor, _, err := argo.GetVersion().MajorMinorPatch(); err == nil {
version = fmt.Sprintf("release-%s.%s", major, minor)
}
return fmt.Sprintf("https://argo-workflows.readthedocs.io/en/%s", version)
Expand All @@ -33,3 +33,11 @@ func scaling() string {
func ConfigureMaximumRecursionDepth() string {
return scaling() + "#maximum-recursion-depth"
}

func metrics() string {
return root() + "/metrics/"
}

func MetricHelp(metricName string) string {
return fmt.Sprintf("%s#%s", metrics(), metricName)
}
Loading

0 comments on commit 9756bab

Please sign in to comment.