diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe87a1b..b1c6b086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- New optional ``label_keys`` parameter for ``counter()`` and ``gauge()`` metrics. ## [1.3.1] - 2025-02-24 diff --git a/doc/monitoring/api_reference.rst b/doc/monitoring/api_reference.rst index 0d93fb9e..51862743 100644 --- a/doc/monitoring/api_reference.rst +++ b/doc/monitoring/api_reference.rst @@ -33,14 +33,18 @@ currently running processes. Use a :ref:`gauge ` ty The design is based on the `Prometheus counter `__. -.. function:: counter(name [, help, metainfo]) +.. function:: counter(name [, help, metainfo, label_keys]) Register a new counter. :param string name: collector name. Must be unique. :param string help: collector description. :param table metainfo: collector metainfo. + :param table label_keys: predefined label keys to optimize performance. + When specified, only these keys can be used in ``label_pairs``. + :return: A counter object. + :rtype: counter_obj .. class:: counter_obj @@ -102,13 +106,15 @@ it might be used for the values that can go up or down, for example, the number The design is based on the `Prometheus gauge `__. -.. function:: gauge(name [, help, metainfo]) +.. function:: gauge(name [, help, metainfo, label_keys]) Register a new gauge. :param string name: collector name. Must be unique. :param string help: collector description. :param table metainfo: collector metainfo. + :param table label_keys: predefined label keys to optimize performance. + When specified, only these keys can be used in ``label_pairs``. :return: A gauge object. diff --git a/metrics/api.lua b/metrics/api.lua index d36f1c9a..b08e6697 100644 --- a/metrics/api.lua +++ b/metrics/api.lua @@ -65,16 +65,16 @@ local function clear() registry:clear() end -local function counter(name, help, metainfo) - checks('string', '?string', '?table') +local function counter(name, help, metainfo, label_keys) + checks('string', '?string', '?table', '?table') - return registry:find_or_create(Counter, name, help, metainfo) + return registry:find_or_create(Counter, name, help, metainfo, label_keys) end -local function gauge(name, help, metainfo) - checks('string', '?string', '?table') +local function gauge(name, help, metainfo, label_keys) + checks('string', '?string', '?table', '?table') - return registry:find_or_create(Gauge, name, help, metainfo) + return registry:find_or_create(Gauge, name, help, metainfo, label_keys) end local function histogram(name, help, buckets, metainfo) diff --git a/metrics/collectors/shared.lua b/metrics/collectors/shared.lua index 2cb195b4..194f3a44 100644 --- a/metrics/collectors/shared.lua +++ b/metrics/collectors/shared.lua @@ -24,7 +24,7 @@ function Shared:new_class(kind, method_names) return setmetatable(class, {__index = methods}) end -function Shared:new(name, help, metainfo) +function Shared:new(name, help, metainfo, label_keys) metainfo = table.copy(metainfo) or {} if not name then @@ -35,6 +35,7 @@ function Shared:new(name, help, metainfo) help = help or "", observations = {}, label_pairs = {}, + label_keys = label_keys, metainfo = metainfo, }, self) end @@ -43,21 +44,49 @@ function Shared:set_registry(registry) self.registry = registry end -function Shared.make_key(label_pairs) - if type(label_pairs) ~= 'table' then +function Shared.make_key(label_pairs, label_keys) + if (label_keys == nil) and (type(label_pairs) ~= 'table') then return "" end + + if label_keys ~= nil then + if type(label_pairs) ~= 'table' then + error("Invalid label_pairs: expected a table when label_keys is provided") + end + + local label_count = 0 + for _ in pairs(label_pairs) do + label_count = label_count + 1 + end + + if #label_keys ~= label_count then + error("Label keys count should match the number of label pairs") + end + + local parts = table.new(#label_keys, 0) + for i, label_key in ipairs(label_keys) do + local label_value = label_pairs[label_key] + if label_value == nil then + error(string.format("Label key '%s' is missing", label_key)) + end + parts[i] = label_value + end + + return table.concat(parts, '\t') + end + local parts = {} for k, v in pairs(label_pairs) do table.insert(parts, k .. '\t' .. v) end table.sort(parts) + return table.concat(parts, '\t') end function Shared:remove(label_pairs) assert(label_pairs, 'label pairs is a required parameter') - local key = self.make_key(label_pairs) + local key = self.make_key(label_pairs, self.label_keys) self.observations[key] = nil self.label_pairs[key] = nil end @@ -67,7 +96,7 @@ function Shared:set(num, label_pairs) error("Collector set value should be a number") end num = num or 0 - local key = self.make_key(label_pairs) + local key = self.make_key(label_pairs, self.label_keys) self.observations[key] = num self.label_pairs[key] = label_pairs or {} end @@ -77,7 +106,7 @@ function Shared:inc(num, label_pairs) error("Collector increment should be a number") end num = num or 1 - local key = self.make_key(label_pairs) + local key = self.make_key(label_pairs, self.label_keys) local old_value = self.observations[key] or 0 self.observations[key] = old_value + num self.label_pairs[key] = label_pairs or {} @@ -88,7 +117,7 @@ function Shared:dec(num, label_pairs) error("Collector decrement should be a number") end num = num or 1 - local key = self.make_key(label_pairs) + local key = self.make_key(label_pairs, self.label_keys) local old_value = self.observations[key] or 0 self.observations[key] = old_value - num self.label_pairs[key] = label_pairs or {} diff --git a/test/collectors/counter_test.lua b/test/collectors/counter_test.lua index 3692f344..9aa4274d 100644 --- a/test/collectors/counter_test.lua +++ b/test/collectors/counter_test.lua @@ -101,3 +101,58 @@ g.test_metainfo_immutable = function() metainfo['my_useful_info'] = 'there' t.assert_equals(c.metainfo, {my_useful_info = 'here'}) end + +g.test_counter_with_fixed_labels = function() + local fixed_labels = {'label1', 'label2'} + local counter = metrics.counter('counter_with_labels', nil, {}, fixed_labels) + + counter:inc(1, {label1 = 1, label2 = 'text'}) + utils.assert_observations(counter:collect(), { + {'counter_with_labels', 1, {label1 = 1, label2 = 'text'}}, + }) + + counter:inc(5, {label2 = 'text', label1 = 2}) + utils.assert_observations(counter:collect(), { + {'counter_with_labels', 1, {label1 = 1, label2 = 'text'}}, + {'counter_with_labels', 5, {label1 = 2, label2 = 'text'}}, + }) + + counter:reset({label1 = 1, label2 = 'text'}) + utils.assert_observations(counter:collect(), { + {'counter_with_labels', 0, {label1 = 1, label2 = 'text'}}, + {'counter_with_labels', 5, {label1 = 2, label2 = 'text'}}, + }) + + counter:remove({label1 = 2, label2 = 'text'}) + utils.assert_observations(counter:collect(), { + {'counter_with_labels', 0, {label1 = 1, label2 = 'text'}}, + }) +end + +g.test_counter_missing_label = function() + local fixed_labels = {'label1', 'label2'} + local counter = metrics.counter('counter_with_labels', nil, {}, fixed_labels) + + counter:inc(42, {label1 = 1, label2 = 'text'}) + utils.assert_observations(counter:collect(), { + {'counter_with_labels', 42, {label1 = 1, label2 = 'text'}}, + }) + + t.assert_error_msg_contains( + "Invalid label_pairs: expected a table when label_keys is provided", + counter.inc, counter, 42, 1) + + t.assert_error_msg_contains( + "Label keys count should match the number of label pairs", + counter.inc, counter, 42, {label1 = 1, label2 = 'text', label3 = 42}) + + local function assert_missing_label_error(fun, ...) + t.assert_error_msg_contains( + "is missing", + fun, counter, ...) + end + + assert_missing_label_error(counter.inc, 1, {label1 = 1, label3 = 'a'}) + assert_missing_label_error(counter.reset, {label2 = 0, label3 = 'b'}) + assert_missing_label_error(counter.remove, {label2 = 0, label3 = 'b'}) +end diff --git a/test/collectors/gauge_test.lua b/test/collectors/gauge_test.lua index cc50914a..8b97a588 100644 --- a/test/collectors/gauge_test.lua +++ b/test/collectors/gauge_test.lua @@ -88,3 +88,65 @@ g.test_metainfo_immutable = function() metainfo['my_useful_info'] = 'there' t.assert_equals(c.metainfo, {my_useful_info = 'here'}) end + +g.test_gauge_with_fixed_labels = function() + local fixed_labels = {'label1', 'label2'} + local gauge = metrics.gauge('gauge_with_labels', nil, {}, fixed_labels) + + gauge:set(1, {label1 = 1, label2 = 'text'}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', 1, {label1 = 1, label2 = 'text'}}, + }) + + gauge:set(42, {label2 = 'text', label1 = 100}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', 1, {label1 = 1, label2 = 'text'}}, + {'gauge_with_labels', 42, {label1 = 100, label2 = 'text'}}, + }) + + gauge:inc(5, {label2 = 'text', label1 = 100}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', 1, {label1 = 1, label2 = 'text'}}, + {'gauge_with_labels', 47, {label1 = 100, label2 = 'text'}}, + }) + + gauge:dec(11, {label1 = 1, label2 = 'text'}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', -10, {label1 = 1, label2 = 'text'}}, + {'gauge_with_labels', 47, {label1 = 100, label2 = 'text'}}, + }) + + gauge:remove({label2 = 'text', label1 = 100}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', -10, {label1 = 1, label2 = 'text'}}, + }) +end + +g.test_gauge_missing_label = function() + local fixed_labels = {'label1', 'label2'} + local gauge = metrics.gauge('gauge_with_labels', nil, {}, fixed_labels) + + gauge:set(42, {label1 = 1, label2 = 'text'}) + utils.assert_observations(gauge:collect(), { + {'gauge_with_labels', 42, {label1 = 1, label2 = 'text'}}, + }) + + t.assert_error_msg_contains( + "Invalid label_pairs: expected a table when label_keys is provided", + gauge.set, gauge, 42, 'text') + + t.assert_error_msg_contains( + "Label keys count should match the number of label pairs", + gauge.set, gauge, 42, {label1 = 1, label2 = 'text', label3 = 42}) + + local function assert_missing_label_error(fun, ...) + t.assert_error_msg_contains( + "is missing", + fun, gauge, ...) + end + + assert_missing_label_error(gauge.inc, 1, {label1 = 1, label3 = 42}) + assert_missing_label_error(gauge.dec, 2, {label1 = 1, label3 = 42}) + assert_missing_label_error(gauge.set, 42, {label2 = 'text', label3 = 42}) + assert_missing_label_error(gauge.remove, {label2 = 'text', label3 = 42}) +end