Skip to content

Commit

Permalink
refactor: autogenerate metrics docs
Browse files Browse the repository at this point in the history
Signed-off-by: Alan Clucas <[email protected]>
  • Loading branch information
Joibel committed Nov 28, 2024
1 parent f22ae3b commit acdca3d
Show file tree
Hide file tree
Showing 31 changed files with 1,578 additions and 439 deletions.
21 changes: 17 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ lint: server/static/files.go $(GOPATH)/bin/golangci-lint

# for local we have a faster target that prints to stdout, does not use json, and can cache because it has no coverage
.PHONY: test
test: server/static/files.go
test: server/static/files.go util/telemetry/metrics_list.go util/telemetry/attributes.go
go build ./...
env KUBECONFIG=/dev/null $(GOTEST) ./...
# marker file, based on it's modification time, we know how long ago this target was run
Expand Down Expand Up @@ -632,8 +632,21 @@ clean:
go clean
rm -Rf test-results node_modules vendor v2 v3 argoexec-linux-amd64 dist/* ui/dist

# swagger
# Built telemetry files
TELEMETRY_BUILDER := $(shell find util/telemetry/builder -type f -name '*.go')
docs/metrics.md: $(TELEMETRY_BUILDER) util/telemetry/builder/values.yaml
@echo Rebuilding $@
go run ./util/telemetry/builder --metricsDocs $@

util/telemetry/metrics_list.go: $(TELEMETRY_BUILDER) util/telemetry/builder/values.yaml
@echo Rebuilding $@
go run ./util/telemetry/builder --metricsListGo $@

util/telemetry/attributes.go: $(TELEMETRY_BUILDER) util/telemetry/builder/values.yaml
@echo Rebuilding $@
go run ./util/telemetry/builder --attributesGo $@

# swagger
pkg/apis/workflow/v1alpha1/openapi_generated.go: $(GOPATH)/bin/openapi-gen $(TYPES)
# These files are generated on a v3/ folder by the tool. Link them to the root folder
[ -e ./v3 ] || ln -s . v3
Expand Down Expand Up @@ -718,7 +731,7 @@ ifneq ($(USE_NIX), true)
endif

.PHONY: docs-spellcheck
docs-spellcheck: /usr/local/bin/mdspell
docs-spellcheck: /usr/local/bin/mdspell docs/metrics.md
# check docs for spelling mistakes
mdspell --ignore-numbers --ignore-acronyms --en-us --no-suggestions --report $(shell find docs -name '*.md' -not -name upgrading.md -not -name README.md -not -name fields.md -not -name upgrading.md -not -name executor_swagger.md -not -path '*/cli/*')
# alphabetize spelling file -- ignore first line (comment), then sort the rest case-sensitive and remove duplicates
Expand All @@ -743,7 +756,7 @@ endif


.PHONY: docs-lint
docs-lint: /usr/local/bin/markdownlint
docs-lint: /usr/local/bin/markdownlint docs/metrics.md
# lint docs
markdownlint docs --fix --ignore docs/fields.md --ignore docs/executor_swagger.md --ignore docs/cli --ignore docs/walk-through/the-structure-of-workflow-specs.md

Expand Down
330 changes: 212 additions & 118 deletions docs/metrics.md

Large diffs are not rendered by default.

58 changes: 24 additions & 34 deletions util/telemetry/attributes.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

192 changes: 192 additions & 0 deletions util/telemetry/builder/builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package main

import (
_ "embed"
"errors"
"flag"
"fmt"
"os"
"regexp"
"slices"
"strings"
"unicode"

"sigs.k8s.io/yaml"
)

const generatedBanner string = "// Code generated by util/telemetry/builder. DO NOT EDIT."

//go:embed values.yaml
var valuesYaml []byte

type attribute struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
// Description is a markdown explanation for the documentation. One line only.
Description string `json:"description"`
}

type allowedAttribute struct {
Name string `json:"name"`
Optional bool `json:"optional,omitempty"`
}

type metric struct {
// Name: Metric name, in CamelCaps
// Will be snake cased for display purposes
Name string `json:"name"`
// Description: short description, emitted on the metrics endpoint and added to the documentation. Do not use marrkdown here.
Description string `json:"description"`
// ExtendedDescription: Markdown capable further description added to the documentation before attributes
ExtendedDescription string `json:"extendedDescription,omitempty"`
// Notes: Markdown capable further description added to the documentation after attributes
Notes string `json:"notes,omitempty"`
Attributes []allowedAttribute `json:"attributes,omitempty"`
// Unit: OpenTelemetry unit of measurement https://opentelemetry.io/docs/specs/otel/metrics/api/#instrument-unit
Unit string `json:"unit"`
Type string `json:"type"`
DefaultBuckets []float64 `json:"defaultBuckets,omitempty"`
}

type attributesList []attribute
type metricsList []metric

type values struct {
Attributes attributesList `json:"attributes"`
Metrics metricsList `json:"metrics"`
}

func load() values {
var vals values
err := yaml.UnmarshalStrict(valuesYaml, &vals)
if err != nil {
panic(err)
}
return vals
}

var collectedErrors []error

func recordErrorString(err string) {
collectedErrors = append(collectedErrors, errors.New(err))
}
func recordError(err error) {
collectedErrors = append(collectedErrors, err)
}

func main() {
metricsDocs := flag.String("metricsDocs", "", "Path to metrics.md in the docs")
attributesGo := flag.String("attributesGo", "", "Path to attributes.go in util/telemetry")
metricsListGo := flag.String("metricsListGo", "", "Path to metrics_list.go in util/telemetry")
flag.Parse()
vals := load()
validate(&vals)
if len(collectedErrors) == 0 {
if metricsDocs != nil && *metricsDocs != "" {
createMetricsDocs(*metricsDocs, &vals.Metrics, &vals.Attributes)
}
if attributesGo != nil && *attributesGo != "" {
createAttributesGo(*attributesGo, &vals.Attributes)
}
if metricsListGo != nil && *metricsListGo != "" {
createMetricsListGo(*metricsListGo, &vals.Metrics)
}
}
if len(collectedErrors) > 0 {
for _, err := range collectedErrors {
fmt.Println(err)
}
os.Exit(1)
}
}

func upperToSnake(in string) string {
runes := []rune(in)
in = string(append([]rune{unicode.ToLower(runes[0])}, runes[1:]...))
re := regexp.MustCompile(`[A-Z]`)
return string(re.ReplaceAllFunc([]byte(in), func(in []byte) []byte {
return []byte(fmt.Sprintf("_%s", strings.ToLower(string(in[0]))))
}))
}

func (a *attribute) displayName() string {
name := a.Name
if a.DisplayName != "" {
name = a.DisplayName
}
return upperToSnake(name)
}

func validateMetricsAttributes(metrics *metricsList, attributes *attributesList) {
for _, metric := range *metrics {
for _, attribute := range metric.Attributes {
if getAttribByName(attribute.Name, attributes) == nil {
recordErrorString(fmt.Sprintf("Metric %s: attribute %s not defined", metric.Name, attribute.Name))
}
}
}
}

func validateAttributes(attributes *attributesList) {
if !slices.IsSortedFunc(*attributes, func(a, b attribute) int {
return strings.Compare(a.Name, b.Name)
}) {
recordErrorString("Attributes must be alphabetically sorted by Name")
}
for _, attribute := range *attributes {
if strings.Contains(attribute.Description, "\n") {
recordErrorString(fmt.Sprintf("%s: Description must be a single line", attribute.Name))
}
}
}

func validateMetrics(metrics *metricsList) {
if !slices.IsSortedFunc(*metrics, func(a, b metric) int {
return strings.Compare(a.Name, b.Name)
}) {
recordErrorString("Metrics must be alphabetically sorted by Name")
}
for _, metric := range *metrics {
// This is easier than enum+custom JSON unmarshall as this is not critical code
switch metric.Type {
case "Float64Histogram":
case "Float64ObservableGauge":
case "Int64Counter":
case "Int64UpDownCounter":
case "Int64ObservableGauge":
break
default:
recordErrorString(fmt.Sprintf("%s: Invalid metric type %s", metric.Name, metric.Type))
}
if strings.Contains(metric.Description, "\n") {
recordErrorString(fmt.Sprintf("%s: Description must be a single line", metric.Name))
}
if strings.HasSuffix(metric.Description, ".") {
recordErrorString(fmt.Sprintf("%s: Description must not have a trailing period", metric.Name))
}
}
}

func validate(vals *values) {
validateAttributes(&vals.Attributes)
validateMetrics(&vals.Metrics)
validateMetricsAttributes(&vals.Metrics, &vals.Attributes)
}

func (m *metric) instrumentType() string {
return m.Type
}

func (m *metric) displayName() string {
name := m.Name
return upperToSnake(name)
}

func getAttribByName(name string, attribs *attributesList) *attribute {
for _, attrib := range *attribs {
if name == attrib.Name {
return &attrib
}
}
return nil
}
Loading

0 comments on commit acdca3d

Please sign in to comment.