| name | add-new-metrics |
| description | Step-by-step guide for adding new Prometheus metrics or entire metric collection modules to the klipper exporter. |
Adding New Metrics
This skill covers every file that must be created or modified when adding new
metrics or a new collection module to the Prometheus Klipper Exporter.
Always run verification after making changes:
make build && make test && (cd docs && npm run build)
Step 1 — Collector Implementation
1A. New collector file (collector/<name>.go)
Pattern for a simple module:
package collector
import (
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
)
type MoonrakerYourModuleResponse struct {
Result struct {
SomeField string `json:"some_field"`
} `json:"result"`
}
func (c Collector) collectYourModule(ch chan<- prometheus.Metric) {
log.Infof("Collecting your_module for %s", c.target)
var result MoonrakerYourModuleResponse
if err := c.fetchFromMoonraker("/api/path", &result); err != nil {
log.Error(err)
return
}
c.emitGauge(ch, "klipper_your_metric", "Description.", float64Value)
c.emitCounter(ch, "klipper_your_counter_total", "Description.", float64Value)
emitStateInfoMetric(ch, "klipper_your_state_info", "Description.", "state", result.Result.SomeField)
labels := []string{"sensor"}
desc := prometheus.NewDesc("klipper_your_labeled_metric", "Description", labels, nil)
for key, value := range someMap {
ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, value, GetValidLabelName(key))
}
c.emitGauge(ch, "klipper_your_bool", "Description.", boolToFloat64(someBool))
}
Key patterns:
- Response structs:
Moonraker<ModuleName>Response with anonymous Result struct{}
- Exception:
printer_object.go uses custom UnmarshalJSON and mapstructure for dynamic keys
- Exception:
mmu.go uses MMUResponse / MMUStatus naming
- Functions that make distinct API calls:
fetch<MoonrakerObject>() helper methods
1B. Adding metrics to an existing module
If adding to a file that already exists (e.g. process_stats.go, printer_object.go):
- Add new fields to the response struct if a new API field is needed
- Add metric emission inside the existing
collect*() method
Step 2 — Register in Collect()
File: collector/collector.go
Add a new slices.Contains guard in the Collect() method:
if slices.Contains(c.modules, "your_module") {
c.collectYourModule(ch)
}
- Maintain alphabetical-ish grouping (existing modules are grouped logically)
- If the module shares an endpoint with another (like
process_stats/network_stats share /machine/proc_stats), gate them together
- If deprecating a module, keep the old name with a
log.Errorf warning
Step 3 — Default Modules
File: main.go
If the module should be enabled by default (recommended for lightweight modules
that provide universally useful metrics), add it to the default list:
modules := []string{"server_info", "process_stats", "job_queue", "system_info"}
Modules that are opt-in (heavy, niche, or have side effects): leave out of the
default list.
Step 4 — Documentation
4A. Create metric reference page
File: docs/metrics/<name>.md
Template:
# Your Module Name
**Module:** `<module_name>` (default|optional)
**API Endpoint:** [`/api/path`](https://moonraker.readthedocs.io/en/latest/web_api/#anchor)
Brief description.
## Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `klipper_your_metric` | Gauge | Description |
| `klipper_your_state_info` | Gauge=1 | Description with `label` label |
## Example PromQL
```promql
# Comment
klipper_your_metric
Type conventions:
Gauge — unlabeled scalar gauge
Counter — unlabeled scalar counter
Gauge=1 — info-style gauge with label (emitted via emitStateInfoMetric)
- Include labels column when metrics have labels
4B. Update sidebar
File: docs/.vitepress/config.js
Add a new sidebar entry in the alphabetical position:
{ text: 'Your Module', link: '/metrics/your-module' },
4C. Update summary index
File: docs/metrics/index.md
- Module overview table — add row with module name, default marker, API endpoint link, and total metric count
- Default modules sentence — update if adding a new default module
- All Metrics by Module — add a
### your_module subsection with a compact metric table and [Full reference →](./your-module) link
- If adding metrics to an existing module, update the metric count in the overview table and add rows to the existing subsection table
- For large modules (MMU, printer_objects), use category tables instead of individual metric rows
Step 5 — Grafana Dashboards
Files: test/grafana/provisioning/dashboards/*.json
5A. When to update dashboards
- New metric added to an existing module — add a panel to the corresponding dashboard if the metric is useful for visualisation
- New module — add a new dashboard JSON file if the module provides enough meaningful data for a dedicated view, OR add panels to an existing dashboard
5B. Panel patterns
Stat panel (numeric value):
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{
"options": { "0": { "text": "Disconnected" }, "1": { "text": "Connected" } },
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
}
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"targets": [{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "klipper_your_metric{job=\"$job\", instance=\"$instance\"}",
"instant": true,
"legendFormat": "__auto"
}],
"title": "Your Title",
"type": "stat"
}
Stat panel (info label display — Gauge=1 with label):
{
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "name"
},
"targets": [{
"expr": "klipper_your_state_info{job=\"$job\", instance=\"$instance\"}",
"instant": true,
"legendFormat": "{{state}}"
}]
}
Stat panel (count of labeled instances):
{
"targets": [{
"expr": "count(klipper_component_info{job=\"$job\", instance=\"$instance\"})",
"instant": true
}]
}
Timeseries panel:
Use "type": "timeseries" and include custom field config with axis/line settings.
5C. Template variables
Every dashboard should define job and instance template variables:
"templating": {
"list": [
{
"name": "job",
"query": { "query": "label_values(job)", "refId": "StandardVariableQuery" },
"type": "query",
"hide": 0
},
{
"name": "instance",
"query": { "query": "label_values(up{job=\"$job\"}, instance)", "refId": "StandardVariableQuery" },
"type": "query",
"hide": 0
}
]
}
Use up{job="$job"} for the instance query so it works even before module-specific
metrics exist.
5D. Provisioning
- Dashboard JSON goes in
test/grafana/provisioning/dashboards/
- The YAML provisioner at
test/grafana/provisioning/dashboards/klipper.yml auto-discovers all .json files in the same directory — no registration needed
- Update the dashboard table in
docs/developers/index.md if adding a new dashboard
- Update the dashboard table in
test/README.md if one exists
Step 6 — Tests
6A. When to add tests
- Unit test: label sanitization, helper functions
- Integration test: verify metrics are emitted with correct names from a fixture.
6B. Integration test pattern
File: tests/<module>_test.go
package test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/scross01/prometheus-klipper-exporter/collector"
)
func TestYourModuleMetrics(t *testing.T) {
fixture := `{ "result": { ... } }`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(fixture))
}))
defer server.Close()
c := collector.New(r.Context(), server.Listener.Addr().String(), []string{"your_module"}, "")
ch := make(chan prometheus.Metric, 100)
go func() {
c.Collect(ch)
close(ch)
}()
var collected []string
for m := range ch {
collected = append(collected, m.Desc().String())
}
}
6C. Test fixtures
- Store large JSON fixtures in
tests/<name>_response.json
- Load with
os.ReadFile + json.Unmarshal
- For dynamic responses, construct maps and
json.Marshal in the test
6D. Run tests
make test
Step 7 — Example and Deployment Configs
7A. Prometheus scrape config
Files:
example/prometheus.yml — production example
test/prometheus.yml — dev/test environment
If adding a new module, add it to the params.modules list in both files if it
should be scraped by default in those environments:
params:
modules:
- your_module
- process_stats
- ...
7B. Virtual printer addon config
File: test/printer_data/config/addons/
If the module exercises a Klipper printer feature that isn't covered by existing
addon configs:
- Check pin conflicts in the existing addon table (in
docs/developers/index.md)
- Create a new
.cfg file in test/printer_data/config/addons/
- Add
[include addons/your_file.cfg] to test/printer_data/config/printer.cfg
- Update the addon table in
docs/developers/index.md
- Update the addon table in
test/README.md
Step 8 — Changelog
File: CHANGELOG.md
Add entries under the current vX.Y.Z heading using present tense bullet points:
- Add `klipper_your_metric` and `klipper_your_other_metric` metrics to `your_module` module
- Add `your_module` module with Apollo integration
- Add corresponding panels to Grafana dashboards (dashboard-name)
- Add support for `<feature>` with `<addon.cfg>` config section in virtual test environment
Full Checklist
Use this checklist to ensure nothing is missed: