aboutsummaryrefslogtreecommitdiff
path: root/microprom/labels.go
blob: db1d609bcfc4367ca385814a21d5208d4dde27eb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
// SPDX-License-Identifier: Apache-2.0

package microprom

import (
	"fmt"
	"strings"
)

// Labels holds a label set, formatted according to the text protocol of [OpenMetrics 1.0].
// Instances are constructed through [MetricSet.FormatLabels].
//
// [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/
type Labels string

// LabelNames holds a set of label names.
type LabelNames struct {
	// NOTE: This is an opaque struct because, when adding support for OpenMetrics 2.0,
	// it will be useful to precompute escaped forms for these names where necessary.
	//
	// Another interesting addition might be alphabetical sorting of labels, where we
	// would need to remember the sort order because we need to apply it to the values
	// slice during Format().
	names []string
}

// NewLabelNames constructs a LabelNames instance.
//
// Per the [OpenMetrics 1.0] spec, label names must match the following regular expression:
//
//	[a-zA-Z_][a-zA-Z0-9_]*
//
// [OpenMetrics 1.0]: https://prometheus.io/docs/specs/om/open_metrics_spec/
func NewLabelNames(names ...string) LabelNames {
	for _, name := range names {
		if !labelNameRx.MatchString(name) {
			panic(fmt.Sprintf("invalid label name: %q", name))
		}
	}
	return LabelNames{names}
}

// FormatLabels serializes a Prometheus labelset into the string format used in Prometheus text expositions.
// For example:
//
//	// once, e.g. during func init()
//	var names = microprom.NewLabelNames("foo", "hello")
//
//	// during microprom.Handler.Collect()
//	labels := ms.FormatLabels(names, "bar", "world")
//	assert.Equal(t, labels, `foo="bar",hello="world"`)
func (ms *MetricSet) FormatLabels(n LabelNames, values ...string) Labels {
	// NOTE on API structure:
	//   - This is not part of ms.Add() to allow reusing label sets for multiple metrics.
	//   - This is a method of MetricSet because we want to be able to depend on the negotiated syntax in the future (e.g. for escaping rules).

	if len(n.names) != len(values) {
		panic(fmt.Sprintf("expected %d label values, but got %d", len(n.names), len(values)))
	}
	if len(n.names) == 0 {
		return ""
	}

	// estimate the perfect number of bytes for the result string to avoid reallocations
	capacity := len(n.names) - 1 // number of "," between pairs
	needsEscaping := make([]bool, len(n.names))
	for idx, value := range values {
		// base length for an encoding in the form `label="value"`
		capacity += len(n.names[idx]) + len(value) + 3
		// some characters within `value` need escaping (TODO: this could be optimized to only iterate through `value` once)
		toEscape := strings.Count(value, "\n") + strings.Count(value, "\"") + strings.Count(value, "\\")
		needsEscaping[idx] = toEscape > 0
		capacity += toEscape
	}

	var b strings.Builder
	b.Grow(capacity)
	for idx, value := range values {
		if idx > 0 {
			_ = b.WriteByte(',')
		}
		_, _ = b.WriteString(n.names[idx])
		_ = b.WriteByte('=')
		_ = b.WriteByte('"')
		if needsEscaping[idx] {
			// TODO: this could be optimized, but since this branch is unlikely in practice, I did not bother yet
			value = strings.ReplaceAll(value, "\\", "\\\\")
			value = strings.ReplaceAll(value, "\"", "\\\"")
			value = strings.ReplaceAll(value, "\n", "\\n")
		}
		_, _ = b.WriteString(value)
		_ = b.WriteByte('"')
	}
	return Labels(b.String())
}