internal/output/prometheus/textfile.go

// Package prometheus writes node_exporter textfile-compatible metrics.
//
// See mercemay.top/src/portr/ for context.
package prometheus

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/mercemay/portr/internal/check"
)

// Writer writes a .prom file suitable for the textfile collector.
type Writer struct {
	Path string
}

// New returns a Writer that will output to path (parent dir must exist).
func New(path string) *Writer { return &Writer{Path: path} }

// Name satisfies output.Named.
func (*Writer) Name() string { return "prometheus" }

// Write renders metrics to w (used by tests). The Path-based Flush is
// the usual entry point from the CLI.
func (*Writer) Write(w io.Writer, r check.Report) error {
	fmt.Fprintln(w, "# HELP portr_target_open 1 if reachable, 0 if not.")
	fmt.Fprintln(w, "# TYPE portr_target_open gauge")
	for _, res := range r.Results {
		val := 0
		if res.Open {
			val = 1
		}
		fmt.Fprintf(w, "portr_target_open{host=%q,port=\"%d\",name=%q} %d\n",
			res.Target.Host, res.Target.Port, res.Target.Name, val)
	}
	fmt.Fprintln(w, "# HELP portr_target_latency_seconds Last observed latency.")
	fmt.Fprintln(w, "# TYPE portr_target_latency_seconds gauge")
	for _, res := range r.Results {
		fmt.Fprintf(w, "portr_target_latency_seconds{host=%q,port=\"%d\"} %f\n",
			res.Target.Host, res.Target.Port, res.Latency.Seconds())
	}
	return nil
}

// Flush writes to Writer.Path atomically (temp file + rename).
func (p *Writer) Flush(r check.Report) error {
	dir, base := filepath.Split(p.Path)
	if !strings.HasSuffix(base, ".prom") {
		return fmt.Errorf("prometheus: path must end in .prom (got %q)", base)
	}
	tmp, err := os.CreateTemp(dir, base+".tmp-*")
	if err != nil {
		return err
	}
	if err := p.Write(tmp, r); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmp.Name())
		return err
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmp.Name())
		return err
	}
	return os.Rename(tmp.Name(), p.Path)
}