mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
df84cea924
## 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
206 lines
5.4 KiB
Go
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
|
|
}
|