Files
coder/scripts/metricsdocgen/main.go
T
Susana Ferreira df84cea924 feat(scripts/metricsdocgen): support merging static and generated metrics files (#21464)
## Description

This PR refactors `scripts/metricsdocgen/main.go` to support merging static and generated metrics files for documentation generation.

The static `metrics` file remains necessary for metrics not defined in the coder codebase (`go_*`, `process_*`, `promhttp_*`, `coder_aibridged_*`), as well as **edge cases** the scanner cannot handle (e.g.,  such as metrics with runtime-determined labels or function-local variable references for fields, ...). Handling these edge cases in the scanner would make it significantly more complex, so we keep this hybrid approach to accommodate them. This means that in such cases, developers need to update the `metrics` file directly, meaning there is still a risk of out-of-date information in the documentation. However, this solution should already encompass most cases.

Static metrics take priority over generated metrics when both files contain the same metric name, allowing manual overrides without modifying the scanner. Some of these edge cases could be easily fixed by updating the codebase to use one of the supported patterns.

## Changes

* Update `scripts/metricsdocgen/main.go` to read from two separate metrics files:
  * `metrics`: static, manually maintained metrics (e.g., `go_*`, `process_*`, `promhttp_*`, `coder_aibridged_*`)
  * `generated_metrics`: auto-generated by the AST scanner
* Update `metrics` file to contain only static and edge-case metrics
* Skip metrics with empty HELP descriptions in the scanner
* Update `generated_metrics` to reflect skipped metrics
* Update `docs/admin/integrations/prometheus.md` with merged metrics

Related to: https://github.com/coder/coder/issues/13223

**Disclosure:** This PR was mainly developed with Claude Sonnet 4, with iterative review and refinement by @ssncferreira
2026-02-13 12:19:33 +00:00

206 lines
5.4 KiB
Go

package main
import (
"bytes"
"errors"
"flag"
"io"
"log"
"os"
"sort"
"strings"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"golang.org/x/xerrors"
)
var (
staticMetricsFile string
prometheusDocFile string
generatedMetricsFile string
dryRun bool
generatorPrefix = []byte("<!-- Code generated by 'make docs/admin/integrations/prometheus.md'. DO NOT EDIT -->")
generatorSuffix = []byte("<!-- End generated by 'make docs/admin/integrations/prometheus.md'. -->")
)
func main() {
flag.StringVar(&staticMetricsFile, "static-metrics", "scripts/metricsdocgen/metrics", "Path to static metrics file (manually maintained)")
flag.StringVar(&generatedMetricsFile, "generated-metrics", "scripts/metricsdocgen/generated_metrics", "Path to generated metrics file (from scanner)")
flag.StringVar(&prometheusDocFile, "prometheus-doc-file", "docs/admin/integrations/prometheus.md", "Path to Prometheus doc file")
flag.BoolVar(&dryRun, "dry-run", false, "Dry run")
flag.Parse()
metrics, err := readAndMergeMetrics()
if err != nil {
log.Fatal("can't read metrics: ", err)
}
doc, err := readPrometheusDoc()
if err != nil {
log.Fatal("can't read Prometheus doc: ", err)
}
doc, err = updatePrometheusDoc(doc, metrics)
if err != nil {
log.Fatal("can't update Prometheus doc: ", err)
}
if dryRun {
log.Println(string(doc))
return
}
err = writePrometheusDoc(doc)
if err != nil {
log.Fatal("can't write updated Prometheus doc: ", err)
}
}
// readMetricsFromFile reads metrics from a single Prometheus text format file.
func readMetricsFromFile(path string) ([]*dto.MetricFamily, error) {
f, err := os.Open(path)
if err != nil {
return nil, xerrors.Errorf("can't open metrics file %s: %w", path, err)
}
defer f.Close()
var metrics []*dto.MetricFamily
decoder := expfmt.NewDecoder(f, expfmt.NewFormat(expfmt.TypeTextPlain))
for {
var m dto.MetricFamily
err = decoder.Decode(&m)
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, xerrors.Errorf("decoding metrics from %s: %w", path, err)
}
metrics = append(metrics, &m)
}
return metrics, nil
}
// readAndMergeMetrics reads metrics from both generated and static files,
// merges them, and returns a sorted list. Generated metrics are produced
// by the AST scanner that extracts metric definitions from the coder source
// code while static metrics are manually maintained (e.g., go_*, process_*,
// external dependencies).
// Note: Static metrics take priority over generated metrics, allowing manual
// overrides for metrics that can't be accurately extracted by the scanner.
func readAndMergeMetrics() ([]*dto.MetricFamily, error) {
generatedMetrics, err := readMetricsFromFile(generatedMetricsFile)
if err != nil {
return nil, xerrors.Errorf("reading generated metrics: %w", err)
}
staticMetrics, err := readMetricsFromFile(staticMetricsFile)
if err != nil {
return nil, xerrors.Errorf("reading static metrics: %w", err)
}
// Merge metrics, using a map to deduplicate by name.
metricsByName := make(map[string]*dto.MetricFamily)
// Add generated metrics first.
for _, m := range generatedMetrics {
metricsByName[*m.Name] = m
}
// Static metrics overwrite generated metrics if they exist.
for _, m := range staticMetrics {
metricsByName[*m.Name] = m
}
// Convert back to slice and sort.
var metrics []*dto.MetricFamily
for _, m := range metricsByName {
metrics = append(metrics, m)
}
sort.Slice(metrics, func(i, j int) bool {
return *metrics[i].Name < *metrics[j].Name
})
return metrics, nil
}
func readPrometheusDoc() ([]byte, error) {
doc, err := os.ReadFile(prometheusDocFile)
if err != nil {
return nil, err
}
return doc, nil
}
func updatePrometheusDoc(doc []byte, metricFamilies []*dto.MetricFamily) ([]byte, error) {
i := bytes.Index(doc, generatorPrefix)
if i < 0 {
return nil, xerrors.New("generator prefix tag not found")
}
tableStartIndex := i + len(generatorPrefix) + 1
j := bytes.Index(doc[tableStartIndex:], generatorSuffix)
if j < 0 {
return nil, xerrors.New("generator suffix tag not found")
}
tableEndIndex := tableStartIndex + j
var buffer bytes.Buffer
_, _ = buffer.Write(doc[:tableStartIndex])
_ = buffer.WriteByte('\n')
_, _ = buffer.WriteString("| Name | Type | Description | Labels |\n")
_, _ = buffer.WriteString("| - | - | - | - |\n")
for _, mf := range metricFamilies {
_, _ = buffer.WriteString("| ")
_, _ = buffer.Write([]byte("`" + *mf.Name + "`"))
_, _ = buffer.WriteString(" | ")
_, _ = buffer.Write([]byte(strings.ToLower(mf.Type.String())))
_, _ = buffer.WriteString(" | ")
if mf.Help != nil {
_, _ = buffer.Write([]byte(*mf.Help))
}
_, _ = buffer.WriteString(" | ")
labels := map[string]struct{}{}
metrics := mf.GetMetric()
for _, m := range metrics {
for _, label := range m.Label {
labels["`"+*label.Name+"`"] = struct{}{}
}
}
if len(labels) > 0 {
_, _ = buffer.WriteString(strings.Join(sortedKeys(labels), " "))
}
_, _ = buffer.WriteString(" |\n")
}
_ = buffer.WriteByte('\n')
_, _ = buffer.Write(doc[tableEndIndex:])
return buffer.Bytes(), nil
}
func writePrometheusDoc(doc []byte) error {
// G306: Expect WriteFile permissions to be 0600 or less
/* #nosec G306 */
err := os.WriteFile(prometheusDocFile, doc, 0o644)
if err != nil {
return err
}
return nil
}
func sortedKeys(m map[string]struct{}) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}