Files
coder/scripts/metricsdocgen/scanner/scanner.go
T
Danny Kopping 7e5e8eb9d2 fix: add ai provider status and reload freshness metrics (#25770) (#25795)
Add metrics for `aibridged` and `aibridgeproxyd`'s provider statuses. AI
providers can be modified, and possibly misconfigured, at runtime. These
metrics help operators understand the state of these provider
definitions in case unexpected behaviour is observed.

(cherry picked from commit 12520ee964)
2026-05-28 18:54:02 +02:00

736 lines
21 KiB
Go

// 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/term"
"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",
"tailnet",
}
// skipPaths lists files that should be excluded from scanning. Their metrics
// must be maintained in the static metrics file instead.
// TODO(ssncferreira): Add support for resolving WrapRegistererWithPrefix to
//
// eliminate the need for this skip list.
var skipPaths = []string{
"coderd/aibridged/metrics.go",
"enterprise/aibridgeproxyd/metrics.go",
}
// 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
}
// metricOpts holds the fields extracted from a prometheus.*Opts struct.
type metricOpts struct {
Namespace string
Subsystem string
Name string
Help string
}
// declarations holds const/var values collected from a file for resolving references.
type declarations struct {
strings map[string]string // string constants/variables
stringSlices map[string][]string // []string variables
}
// packageDeclarations holds exported string constants collected from all scanned files,
// keyed by package name. This allows resolving cross-file references.
// Note: resolution depends on directory scan order in scanDirs, i.e.,
// constants from later directories won't be available when scanning earlier ones.
var packageDeclarations = make(map[string]map[string]string)
// verbose controls whether informational messages are printed to
// stderr. It is true when stdout is a terminal (interactive use)
// and false when stdout is piped (e.g. via atomic_write in make).
var verbose = term.IsTerminal(int(os.Stdout.Fd()))
// logf prints an informational message to stderr only when running
// interactively. Use this for progress and debug output that is
// not actionable.
func logf(format string, args ...any) {
if verbose {
log.Printf(format, args...)
}
}
// warnf prints a warning to stderr unconditionally. Use this for
// messages about real problems that a developer should investigate.
func warnf(format string, args ...any) {
log.Printf("WARNING: "+format, args...)
}
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)
logf("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)
}
logf("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
}
// Skip files listed in skipPaths.
for _, sp := range skipPaths {
if path == sp {
return nil
}
}
fileMetrics, err := scanFile(path)
if err != nil {
return xerrors.Errorf("scanning %s: %w", path, err)
}
if len(fileMetrics) > 0 {
logf("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)
}
// Collect exported constants into the global package declarations map.
collectPackageConsts(file)
// Collect file-local const and var declarations for resolving references.
decls := collectDecls(file)
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, decls)
if ok {
if metric.Help == "" {
warnf("metric %q has no HELP description, skipping", metric.Name)
// Skip metrics without descriptions, they should be fixed in the source code
// or added to the static metrics file with a manual description.
return true
}
metrics = append(metrics, metric)
}
return true
})
return metrics, nil
}
// collectPackageConsts collects exported string constants from a file into
// the global packageDeclarations map, keyed by package name.
func collectPackageConsts(file *ast.File) {
pkgName := file.Name.Name
if packageDeclarations[pkgName] == nil {
packageDeclarations[pkgName] = make(map[string]string)
}
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range valueSpec.Names {
if !ast.IsExported(name.Name) {
continue
}
if i >= len(valueSpec.Values) {
continue
}
if lit, ok := valueSpec.Values[i].(*ast.BasicLit); ok {
if lit.Kind == token.STRING {
packageDeclarations[pkgName][name.Name] = strings.Trim(lit.Value, `"`)
}
}
}
}
}
}
// resolveStringExpr attempts to resolve an expression to a string value.
// Examples:
// - "my_metric": "my_metric" (string literal)
// - metricName: resolved value of metricName constant (identifier)
// - agentmetrics.LabelUsername: resolved from package constants (selector)
func resolveStringExpr(expr ast.Expr, decls declarations) string {
switch e := expr.(type) {
case *ast.BasicLit:
return strings.Trim(e.Value, `"`)
case *ast.Ident:
return decls.strings[e.Name]
case *ast.BinaryExpr:
return resolveBinaryExpr(e, decls)
case *ast.SelectorExpr:
// Handle pkg.Const syntax.
if ident, ok := e.X.(*ast.Ident); ok {
if pkgConsts, ok := packageDeclarations[ident.Name]; ok {
return pkgConsts[e.Sel.Name]
}
}
}
return ""
}
// resolveBinaryExpr resolves a binary expression (string concatenation) to a string.
// It recursively resolves the left and right operands.
// Example:
// - "coderd_" + "api_" + "requests": "coderd_api_requests"
// - namespace + "_" + metricName: resolved concatenation
func resolveBinaryExpr(expr *ast.BinaryExpr, decls declarations) string {
left := resolveStringExpr(expr.X, decls)
right := resolveStringExpr(expr.Y, decls)
if left != "" && right != "" {
return left + right
}
return ""
}
// extractStringSlice extracts a []string from a composite literal.
// Example:
// - []string{"a", "b", myConst}: ["a", "b", <resolved value of myConst>]
func extractStringSlice(lit *ast.CompositeLit, decls declarations) []string {
var labels []string
for _, elt := range lit.Elts {
if label := resolveStringExpr(elt, decls); label != "" {
labels = append(labels, label)
}
}
return labels
}
// collectDecls collects const and var declarations from a file.
// This is used to resolve constant and variable references in metric definitions.
func collectDecls(file *ast.File) declarations {
decls := declarations{
strings: make(map[string]string),
stringSlices: make(map[string][]string),
}
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, name := range valueSpec.Names {
if i >= len(valueSpec.Values) {
continue
}
switch v := valueSpec.Values[i].(type) {
case *ast.BasicLit:
// String literal: const name = "value"
decls.strings[name.Name] = strings.Trim(v.Value, `"`)
case *ast.BinaryExpr:
// Concatenation: const name = prefix + "suffix"
if resolved := resolveBinaryExpr(v, decls); resolved != "" {
decls.strings[name.Name] = resolved
}
case *ast.CompositeLit:
// Slice literal: var labels = []string{"a", "b"}
if resolved := extractStringSlice(v, decls); resolved != nil {
decls.stringSlices[name.Name] = resolved
}
}
}
}
}
return decls
}
// extractLabels extracts label names from an expression passed as an argument
// to a metric constructor. Handles both inline []string literals and
// variable references from decls.
// Examples:
// - []string{"label1", "label2"}: ["label1", "label2"] (inline literal)
// - myLabels: resolved value of myLabels variable (variable reference)
func extractLabels(expr ast.Expr, decls declarations) []string {
switch e := expr.(type) {
case *ast.CompositeLit:
// []string{"label1", "label2"}
return extractStringSlice(e, decls)
case *ast.Ident:
// Variable reference like 'labels'.
if labels, ok := decls.stringSlices[e.Name]; ok {
return labels
}
return nil
}
return nil
}
// extractNewDescMetric extracts a metric from a prometheus.NewDesc() call.
// Pattern: prometheus.NewDesc(name, help, variableLabels, constLabels)
// Currently, coder only uses MustNewConstMetric with NewDesc.
// TODO(ssncferreira): Add support for other MustNewConst* functions if needed.
func extractNewDescMetric(call *ast.CallExpr, decls declarations) (Metric, bool) {
// Check if this is a prometheus.NewDesc call.
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return Metric{}, false
}
// Match calls that are exactly "prometheus.NewDesc()". This checks the local
// package identifier, not the resolved import path. If the prometheus package
// is imported with an alias, this will not match.
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "prometheus" || sel.Sel.Name != "NewDesc" {
return Metric{}, false
}
// NewDesc requires at least 4 arguments: name, help, variableLabels, constLabels
if len(call.Args) < 4 {
return Metric{}, false
}
// Extract name (first argument).
name := resolveStringExpr(call.Args[0], decls)
if name == "" {
warnf("extractNewDescMetric: skipping prometheus.NewDesc() call: could not resolve metric name")
return Metric{}, false
}
// Extract help (second argument).
help := resolveStringExpr(call.Args[1], decls)
// Extract labels (third argument).
labels := extractLabels(call.Args[2], decls)
// Infer metric type from name suffix.
// TODO(ssncferreira): The actual type is determined by the MustNewConst* function
// that uses this descriptor (e.g., MustNewConstMetric with prometheus.CounterValue or
// prometheus.GaugeValue). Currently, coder only uses MustNewConstMetric, so we
// infer the type from naming conventions.
metricType := MetricTypeGauge
if strings.HasSuffix(name, "_total") || strings.HasSuffix(name, "_count") {
metricType = MetricTypeCounter
}
return Metric{
Name: name,
Type: metricType,
Help: help,
Labels: labels,
}, true
}
// parseMetricFuncName parses a prometheus function name and returns the metric type
// and whether it's a Vec type. Returns empty string if not a recognized metric function.
func parseMetricFuncName(funcName string) (MetricType, bool) {
isVec := strings.HasSuffix(funcName, "Vec")
baseName := strings.TrimSuffix(funcName, "Vec")
switch baseName {
case "NewGauge":
return MetricTypeGauge, isVec
case "NewCounter":
return MetricTypeCounter, isVec
case "NewHistogram":
return MetricTypeHistogram, isVec
case "NewSummary":
return MetricTypeSummary, isVec
}
return "", false
}
// extractOpts extracts fields from a prometheus.*Opts composite literal.
func extractOpts(expr ast.Expr, decls declarations) (metricOpts, bool) {
// Handle both direct composite literals and calls that return opts.
var lit *ast.CompositeLit
switch e := expr.(type) {
case *ast.CompositeLit:
lit = e
case *ast.UnaryExpr:
// Handle &prometheus.GaugeOpts{...}
if l, ok := e.X.(*ast.CompositeLit); ok {
lit = l
}
}
if lit == nil {
return metricOpts{}, false
}
var opts metricOpts
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok {
continue
}
value := resolveStringExpr(kv.Value, decls)
switch key.Name {
case "Namespace":
opts.Namespace = value
case "Subsystem":
opts.Subsystem = value
case "Name":
opts.Name = value
case "Help":
opts.Help = value
}
}
return opts, opts.Name != ""
}
// buildMetricName constructs the full metric name from namespace, subsystem, and name.
func buildMetricName(namespace, subsystem, name string) string {
metricNameParts := make([]string, 0, 3)
if namespace != "" {
metricNameParts = append(metricNameParts, namespace)
}
if subsystem != "" {
metricNameParts = append(metricNameParts, subsystem)
}
if name != "" {
metricNameParts = append(metricNameParts, name)
}
// Join non-empty parts with "_" to handle optional namespace/subsystem.
// e.g., ("coderd", "", "agents_up"): "coderd_agents_up"
return strings.Join(metricNameParts, "_")
}
// extractOptsMetric extracts a metric from prometheus.New*() or prometheus.New*Vec() calls.
// Supported patterns:
// - prometheus.NewGauge(prometheus.GaugeOpts{...})
// - prometheus.NewCounter(prometheus.CounterOpts{...})
// - prometheus.NewHistogram(prometheus.HistogramOpts{...})
// - prometheus.NewSummary(prometheus.SummaryOpts{...})
// - prometheus.NewGaugeVec(prometheus.GaugeOpts{...}, labels)
// - prometheus.NewCounterVec(prometheus.CounterOpts{...}, labels)
// - prometheus.NewHistogramVec(prometheus.HistogramOpts{...}, labels)
// - prometheus.NewSummaryVec(prometheus.SummaryOpts{...}, labels)
func extractOptsMetric(call *ast.CallExpr, decls declarations) (Metric, bool) {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return Metric{}, false
}
// Match calls that are exactly "prometheus.New*(...)". This checks the local
// package identifier, not the resolved import path. If the prometheus package
// is imported with an alias, this will not match.
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "prometheus" {
return Metric{}, false
}
funcName := sel.Sel.Name
metricType, isVec := parseMetricFuncName(funcName)
if metricType == "" {
return Metric{}, false
}
// Need at least one argument (the Opts struct).
if len(call.Args) < 1 {
return Metric{}, false
}
// Extract metric info from the Opts struct.
opts, ok := extractOpts(call.Args[0], decls)
if !ok {
warnf("extractOptsMetric: skipping prometheus.%s() call: could not extract opts", funcName)
return Metric{}, false
}
// Extract labels for Vec types.
var labels []string
if isVec && len(call.Args) >= 2 {
labels = extractLabels(call.Args[1], decls)
}
// Build the full metric name.
name := buildMetricName(opts.Namespace, opts.Subsystem, opts.Name)
if name == "" {
warnf("extractOptsMetric: skipping prometheus.%s() call: could not build metric name", funcName)
return Metric{}, false
}
return Metric{
Name: name,
Type: metricType,
Help: opts.Help,
Labels: labels,
}, true
}
// isPromautoCall checks if an expression is a promauto factory call.
// Matches:
// - promauto.With(reg): direct chained call
// - factory: variable that was assigned from promauto.With()
func isPromautoCall(expr ast.Expr) bool {
switch e := expr.(type) {
case *ast.CallExpr:
// Check for promauto.With(reg).New*()
sel, ok := e.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
// Match calls that are exactly "promauto.With(...)". This checks the local
// package identifier, not the resolved import path. If the promauto package
// is imported with an alias, this will not match.
return ident.Name == "promauto" && sel.Sel.Name == "With"
case *ast.Ident:
// Heuristic: assume any identifier that isn't "prometheus" used as a
// receiver for New*() methods is a promauto factory variable.
// This works for the codebase patterns (e.g., factory.NewGaugeVec(...))
// but could false-positive on other receivers. Downstream extractOpts
// validation prevents incorrect metrics from being emitted.
return e.Name != "prometheus"
}
return false
}
// extractPromautoMetric extracts a metric from promauto.With().New*() or factory.New*() calls.
// Supported patterns:
// - promauto.With(reg).NewCounterVec(prometheus.CounterOpts{...}, labels)
// - factory.NewGaugeVec(prometheus.GaugeOpts{...}, labels) where factory := promauto.With(reg)
func extractPromautoMetric(call *ast.CallExpr, decls declarations) (Metric, bool) {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return Metric{}, false
}
funcName := sel.Sel.Name
metricType, isVec := parseMetricFuncName(funcName)
if metricType == "" {
return Metric{}, false
}
// Check if this is a promauto call by examining the receiver.
if !isPromautoCall(sel.X) {
return Metric{}, false
}
// Need at least one argument (the Opts struct).
if len(call.Args) < 1 {
return Metric{}, false
}
// Extract metric info from the Opts struct.
opts, ok := extractOpts(call.Args[0], decls)
if !ok {
warnf("extractPromautoMetric: skipping promauto.%s() call: could not extract opts", funcName)
return Metric{}, false
}
// Extract labels for Vec types.
var labels []string
if isVec && len(call.Args) >= 2 {
labels = extractLabels(call.Args[1], decls)
}
// Build the full metric name.
name := buildMetricName(opts.Namespace, opts.Subsystem, opts.Name)
if name == "" {
warnf("extractPromautoMetric: skipping promauto.%s() call: could not build metric name", funcName)
return Metric{}, false
}
return Metric{
Name: name,
Type: metricType,
Help: opts.Help,
Labels: labels,
}, true
}
// 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(call *ast.CallExpr, decls declarations) (Metric, bool) {
// Check for prometheus.NewDesc() pattern.
if metric, ok := extractNewDescMetric(call, decls); ok {
return metric, true
}
// Check for prometheus.New*() and prometheus.New*Vec() patterns.
if metric, ok := extractOptsMetric(call, decls); ok {
return metric, true
}
// Check for promauto.With(reg).New*() pattern.
if metric, ok := extractPromautoMetric(call, decls); ok {
return metric, true
}
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())
}
}