mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(scripts/metricsdocgen): add AST scanner core for metrics doc generation (#21460)
## Description This PR adds an AST-based scanner to automatically generate Prometheus metrics documentation from the coder source code. ## Changes * Add `scripts/metricsdocgen/scanner/scanner.go` with: * Directory walking for `agent/`, `coderd/`, `enterprise/`, `provisionerd/` * Go file parsing (skipping `*_test.go` files) * AST inspection for metric extraction * `Metric.String()` for Prometheus text exposition format rendering * `writeMetrics()` to output metrics to stdout * Placeholder `extractMetricFromCall()` (implemented in subsequent PRs) * Empty `scripts/metricsdocgen/generated_metrics` placeholder (populated by subsequent PRs) **Note:** To facilitate the review process, this was separated into scoped stacked PRs. The division was based on the main structure, the different Prometheus patterns currently present in the codebase, and updates to the build process. 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
This commit is contained in:
@@ -0,0 +1,207 @@
|
|||||||
|
// Package main provides a tool to scan Go source files and extract Prometheus
|
||||||
|
// metric definitions. It outputs metrics in Prometheus text exposition format
|
||||||
|
// to stdout for use by the documentation generator.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./scripts/metricsdocgen/scanner > scripts/metricsdocgen/generated_metrics
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Directories to scan for metric definitions, relative to the repository root.
|
||||||
|
// Add or remove directories here to control the scanner's scope.
|
||||||
|
var scanDirs = []string{
|
||||||
|
"agent",
|
||||||
|
"coderd",
|
||||||
|
"enterprise",
|
||||||
|
"provisionerd",
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricType represents the type of Prometheus metric.
|
||||||
|
type MetricType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetricTypeCounter MetricType = "counter"
|
||||||
|
MetricTypeGauge MetricType = "gauge"
|
||||||
|
MetricTypeHistogram MetricType = "histogram"
|
||||||
|
MetricTypeSummary MetricType = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metric represents a single Prometheus metric definition extracted from source code.
|
||||||
|
type Metric struct {
|
||||||
|
Name string // Full metric name (namespace_subsystem_name)
|
||||||
|
Type MetricType // counter, gauge, histogram, or summary
|
||||||
|
Help string // Description of the metric
|
||||||
|
Labels []string // Label names for this metric
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
metrics, err := scanAllDirs()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to scan directories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates are not expected since Prometheus enforces unique metric names at registration.
|
||||||
|
uniqueMetrics := make(map[string]Metric)
|
||||||
|
for _, m := range metrics {
|
||||||
|
uniqueMetrics[m.Name] = m
|
||||||
|
}
|
||||||
|
metrics = make([]Metric, 0, len(uniqueMetrics))
|
||||||
|
for _, m := range uniqueMetrics {
|
||||||
|
metrics = append(metrics, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort metrics by name for consistent output across runs.
|
||||||
|
sort.Slice(metrics, func(i, j int) bool {
|
||||||
|
return metrics[i].Name < metrics[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
writeMetrics(metrics, os.Stdout)
|
||||||
|
|
||||||
|
log.Printf("Successfully parsed %d metrics", len(metrics))
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanAllDirs scans all configured directories for metric definitions.
|
||||||
|
func scanAllDirs() ([]Metric, error) {
|
||||||
|
var allMetrics []Metric
|
||||||
|
|
||||||
|
for _, dir := range scanDirs {
|
||||||
|
metrics, err := scanDirectory(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("scanning %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("scanning %s: found %d metrics", dir, len(metrics))
|
||||||
|
allMetrics = append(allMetrics, metrics...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMetrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDirectory recursively walks a directory and extracts metrics from all Go files.
|
||||||
|
func scanDirectory(root string) ([]Metric, error) {
|
||||||
|
var metrics []Metric
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip non-Go files.
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip test files.
|
||||||
|
if strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileMetrics, err := scanFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("scanning %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fileMetrics) > 0 {
|
||||||
|
log.Printf("scanning %s: found %d metrics", path, len(fileMetrics))
|
||||||
|
}
|
||||||
|
metrics = append(metrics, fileMetrics...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return metrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanFile parses a single Go file and extracts all Prometheus metric definitions.
|
||||||
|
func scanFile(path string) ([]Metric, error) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("parsing file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metrics []Metric
|
||||||
|
|
||||||
|
// Walk the AST looking for metric registration calls.
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
metric, ok := extractMetricFromCall(call)
|
||||||
|
if ok {
|
||||||
|
metrics = append(metrics, metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMetricFromCall attempts to extract a Metric from a function call expression.
|
||||||
|
// It returns the metric and true if successful, or an empty metric and false if
|
||||||
|
// the call is not a metric registration.
|
||||||
|
//
|
||||||
|
// Supported patterns:
|
||||||
|
// - prometheus.NewDesc() calls
|
||||||
|
// - prometheus.New*() and prometheus.New*Vec() with *Opts{}
|
||||||
|
// - promauto.With(reg).New*() and factory.New*() patterns
|
||||||
|
func extractMetricFromCall(_ *ast.CallExpr) (Metric, bool) {
|
||||||
|
// TODO(ssncferreira): Implement upstack.
|
||||||
|
// Handle prometheus.NewDesc()
|
||||||
|
// Handle prometheus.New*Vec() and prometheus.New*() with *Opts{}
|
||||||
|
// Handle promauto.With(reg).New*() pattern
|
||||||
|
|
||||||
|
return Metric{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the metric in Prometheus text exposition format.
|
||||||
|
// Label values are empty strings and metric values are 0 since only
|
||||||
|
// metadata (name, type, help, label names) is used for documentation generation.
|
||||||
|
func (m Metric) String() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
// Write HELP line.
|
||||||
|
_, _ = fmt.Fprintf(&buf, "# HELP %s %s\n", m.Name, m.Help)
|
||||||
|
|
||||||
|
// Write TYPE line.
|
||||||
|
_, _ = fmt.Fprintf(&buf, "# TYPE %s %s\n", m.Name, m.Type)
|
||||||
|
|
||||||
|
// Write a sample metric line with empty label values and zero metric value.
|
||||||
|
if len(m.Labels) > 0 {
|
||||||
|
labelPairs := make([]string, len(m.Labels))
|
||||||
|
for i, l := range m.Labels {
|
||||||
|
labelPairs[i] = fmt.Sprintf("%s=\"\"", l)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(&buf, "%s{%s} 0\n", m.Name, strings.Join(labelPairs, ","))
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(&buf, "%s 0\n", m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMetrics writes all metrics in Prometheus text exposition format.
|
||||||
|
func writeMetrics(metrics []Metric, w io.Writer) {
|
||||||
|
for _, m := range metrics {
|
||||||
|
_, _ = fmt.Fprint(w, m.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user