Skip to content

Adding a Collector

This guide walks through adding a new collector to the OPNsense Exporter. The process involves 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 changes to any central registry file are needed.

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