Skip to content

Adding a Collector

This guide adds a new collector to the OPNsense Exporter: five steps across three packages.

Overview

  1. Create the collector file in internal/collector/
  2. Add an init() function for auto-registration
  3. Add a Fetch*() method and data structs in opnsense/
  4. Add a disable flag in internal/options/collectors.go
  5. Wire the disable flag in internal/collector/collector.go and main.go
  6. Update the docs: add SubsystemDisplayNames + CollectorFlags entries, then run make docs

Step 1: Create the collector

Create a new file internal/collector/<subsystem>.go implementing the CollectorInstance interface:

internal/collector/example.go
package collector

import (
    "log/slog"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/rknightion/opnsense-exporter/opnsense"
)

type exampleCollector struct {
    log           *slog.Logger
    instanceLabel string

    // Define your metric descriptors
    exampleGauge *prometheus.Desc
    exampleTotal *prometheus.Desc
}

func init() {
    collectorInstances = append(collectorInstances, &exampleCollector{})
}

func (c *exampleCollector) Name() string {
    return "example" // Must match a subsystem constant
}

func (c *exampleCollector) Register(namespace, instance string, log *slog.Logger) {
    c.log = log
    c.instanceLabel = instance

    c.exampleGauge = prometheus.NewDesc(
        prometheus.BuildFQName(namespace, "example", "value"),
        "Description of the metric",
        []string{"label1", "label2"},  // variable labels
        prometheus.Labels{
            instanceLabelName: instance,
        },
    )

    c.exampleTotal = prometheus.NewDesc(
        prometheus.BuildFQName(namespace, "example", "items_total"),
        "Total number of example items",
        nil,
        prometheus.Labels{
            instanceLabelName: instance,
        },
    )
}

func (c *exampleCollector) Describe(ch chan<- *prometheus.Desc) {
    ch <- c.exampleGauge
    ch <- c.exampleTotal
}

func (c *exampleCollector) Update(
    client *opnsense.Client,
    ch chan<- prometheus.Metric,
) *opnsense.APICallError {
    data, err := client.FetchExample()
    if err != nil {
        return err
    }

    // Emit the total count
    ch <- prometheus.MustNewConstMetric(
        c.exampleTotal,
        prometheus.GaugeValue,
        float64(len(data.Items)),
    )

    // Emit per-item metrics
    for _, item := range data.Items {
        ch <- prometheus.MustNewConstMetric(
            c.exampleGauge,
            prometheus.GaugeValue,
            item.Value,
            item.Label1,
            item.Label2,
        )
    }

    return nil
}

Step 2: Auto-registration

The init() function in the collector file (shown above) handles registration: it appends the collector instance to the global collectorInstances slice. No central registry file to touch.

Add a subsystem constant to internal/collector/collector.go:

const ExampleSubsystem = "example"

Step 3: Add the API fetch method

Create or modify a file in opnsense/ to add the fetch method and data structures:

opnsense/example.go
package opnsense

// ExampleData represents the API response structure
type ExampleData struct {
    Items []ExampleItem `json:"items"`
}

type ExampleItem struct {
    Label1 string  `json:"label1"`
    Label2 string  `json:"label2"`
    Value  float64 `json:"value"`
}

// FetchExample retrieves example data from the OPNsense API
func (c *Client) FetchExample() (*ExampleData, *APICallError) {
    var data ExampleData

    url, ok := c.endpoints["example"]
    if !ok {
        return nil, &APICallError{
            Endpoint: "example",
            Err:      ErrEndpointNotFound,
        }
    }

    if err := c.do("GET", url, &data); err != nil {
        return nil, &APICallError{
            Endpoint: string(url),
            Err:      err,
        }
    }

    return &data, nil
}

Register the endpoint in the client's endpoint map (in opnsense/client.go or the relevant setup):

"example": "/api/example/endpoint",

Step 4: Add the disable flag

Add a new flag in internal/options/collectors.go:

exampleCollectorDisabled = kingpin.Flag(
    "exporter.disable-example",
    "Disable the scraping of example metrics",
).Envar("OPNSENSE_EXPORTER_DISABLE_EXAMPLE").Default("false").Bool()

Add the field to CollectorsDisableSwitch:

type CollectorsDisableSwitch struct {
    // ... existing fields ...
    Example bool
}

Wire it in CollectorsSwitches():

func CollectorsSwitches() CollectorsDisableSwitch {
    return CollectorsDisableSwitch{
        // ... existing fields ...
        Example: !*exampleCollectorDisabled,
    }
}

Step 5: Wire the disable flag

Add the Without option in internal/collector/collector.go:

func WithoutExampleCollector() Option {
    return withoutCollectorInstance(ExampleSubsystem)
}

Wire it in main.go:

if !collectorsSwitches.Example {
    collectorOptionFuncs = append(collectorOptionFuncs, collector.WithoutExampleCollector())
    logger.Info("example collector disabled")
}

Testing

Add tests for the new collector in internal/collector/example_test.go and for the fetch method in opnsense/example_test.go.

Run the tests:

go test ./internal/collector/ -run TestExample
go test ./opnsense/ -run TestFetchExample

Step 6: Update the documentation

Documentation is generated. Never hand-edit content between <!-- docgen:begin/end --> markers or the docgen-generated pages. After the code is in place:

  1. Add the subsystem's display name to SubsystemDisplayNames in internal/collector/collector.go (a unit test fails without it).
  2. Add a CollectorFlags entry in internal/options/collectors.go binding the new flag to the subsystem string (a unit test fails without it).
  3. Run make docs and commit the result. It regenerates the metrics/collector references, re-injects the flag tables in docs/configuration.md, re-pins metric/collector counts across the site and README, lints all doc flag/env tokens, and cross-checks docs against the live collector registry. CI (make docs-check) fails if any of this is stale.

Checklist

  • Collector implements CollectorInstance interface
  • init() function registers the collector
  • Subsystem constant added to collector.go
  • Fetch*() method and data structs added to opnsense/
  • API endpoint registered in the client (and testEndpoints() / endpoint count updated)
  • Disable flag added to internal/options/collectors.go
  • Without*() option added to collector.go
  • Disable logic wired in main.go
  • Tests added and passing
  • SubsystemDisplayNames entry added in internal/collector/collector.go
  • CollectorFlags entry added in internal/options/collectors.go
  • make docs run and output committed