diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..c57e621 --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,22 @@ +name: Dart CI + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + + container: + image: google/dart:latest + + steps: + - uses: actions/checkout@v1 + - name: Install dependencies + run: pub get + - name: Run tests + run: pub run test + - name: Dart/Flutter Package Analyzer + uses: axel-op/dart_package_analyzer@v2.0.0 + with: + githubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7bf00e8..50602ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,11 @@ -# See https://www.dartlang.org/guides/libraries/private-files - # Files and directories created by pub .dart_tool/ .packages -.pub/ -build/ -# If you're building an application, you may want to check-in your pubspec.lock +# Remove the following pattern if you wish to check in your lock file pubspec.lock +# Conventional directory for build outputs +build/ + # Directory created by dartdoc -# If you don't generate documentation locally you can remove this line. doc/api/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b82c4aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.1.0 + +- Initial version +- Implements `Counter`, `Gauge` and `Histogram`. +- Includes a shelf handler to export metrics and a shelf middleware to measure performance. diff --git a/README.md b/README.md index 7a25114..f6443cb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ -# prometheus_client -A Dart prometheus client library +prometheus_client +=== + +This is a simple Dart implementation of the [Prometheus][prometheus] client library, [similar to to libraries for other languages][writing_clientlibs]. +It supports the default metric types like gauges, counters, or histograms. +Metrics can be exported using the [text format][text_format]. +To expose them in your server application the package comes with a [shelf][shelf] handler. +In addition, it comes with some plug-in ready metrics for the Dart runtime and shelf. + +You can find the latest updates in the [changelog][changelog]. + +## Usage + +A simple usage example: + +```dart +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/runtime_metrics.dart' as runtime_metrics; +import 'package:prometheus_client/shelf_handler.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; + +main() async { + // Register default runtime metrics + runtime_metrics.register(); + + // Create a metric of type counter. + // Always register your metric, either at the default registry or a custom one. + final greetingCounter = + Counter('greetings_total', 'The total amount of greetings')..register(); + final app = Router(); + + app.get('/hello', (shelf.Request request) { + // Every time the hello is called, increase the counter by one + greetingCounter.inc(); + return shelf.Response.ok('hello-world'); + }); + + // Register a handler to expose the metrics in the Prometheus text format + app.get('/metrics', prometheusHandler()); + + var handler = const shelf.Pipeline() + .addHandler(app.handler); + var server = await io.serve(handler, 'localhost', 8080); + + print('Serving at http://${server.address.host}:${server.port}'); +} +``` + +Start the example application and access the exposed metrics at `http://localhost:8080/metrics`. +For a full usage example, take a look at [`example/prometheus_client.example.dart`][example]. + +## Planned features + +To achieve the requirements from the Prometheus [Writing Client Libraries][writing_clientlibs] documentation, some features still have to be implemented: + +* Support `Summary` metric type. +* Support timestamp in samples and text format. +* Split out shelf support into own package to avoid dependencies on shelf. + + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/Fox32/prometheus_client/issues +[writing_clientlibs]: https://prometheus.io/docs/instrumenting/writing_clientlibs/ +[prometheus]: https://prometheus.io/ +[text_format]: https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format +[shelf]: https://pub.dev/packages/shelf +[example]: ./example/prometheus_client_example.dart +[changelog]: ./CHANGELOG.md diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..d520978 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +# Defines a default set of lint rules enforced for +# projects at Google. For details and rationale, +# see https://github.com/dart-lang/pedantic#enabled-lints. +include: package:pedantic/analysis_options.yaml diff --git a/example/prometheus_client_example.dart b/example/prometheus_client_example.dart new file mode 100644 index 0000000..f92853c --- /dev/null +++ b/example/prometheus_client_example.dart @@ -0,0 +1,60 @@ +import 'dart:math'; + +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/runtime_metrics.dart' as runtime_metrics; +import 'package:prometheus_client/shelf_metrics.dart' as shelf_metrics; +import 'package:prometheus_client/shelf_handler.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; + +main() async { + runtime_metrics.register(); + + // Create a labeled gauge metric that stores the last time an endpoint was + // accessed. Always register your metric, either at the default registry or a + // custom one. + final timeGauge = Gauge( + 'last_accessed_time', 'The last time the hello endpoint was accessed', + labelNames: ['endpoint']) + ..register(); + // Create a gauge metric without labels to store the last rolled value + final rollGauge = Gauge('roll_value', 'The last roll value')..register(); + // Create a metric of type counter + final greetingCounter = + Counter('greetings_total', 'The total amount of greetings')..register(); + + final app = Router(); + + app.get('/hello', (shelf.Request request) { + // Set the current time to the time metric for the label 'hello' + timeGauge..labels(['hello']).setToCurrentTime(); + // Every time the hello is called, increase the counter by one + greetingCounter.inc(); + return shelf.Response.ok('hello-world'); + }); + + app.get('/roll', (shelf.Request request) { + timeGauge..labels(['roll']).setToCurrentTime(); + final value = Random().nextDouble(); + // Store the rolled value without labels + rollGauge.value = value; + return shelf.Response.ok('rolled $value'); + }); + + // Register a handler to expose the metrics in the Prometheus text format + app.get('/metrics', prometheusHandler()); + + app.all('/', (shelf.Request request) { + return shelf.Response.notFound('Not Found'); + }); + + var handler = const shelf.Pipeline() + // Register a middleware to track request times + .addMiddleware(shelf_metrics.register()) + .addMiddleware(shelf.logRequests()) + .addHandler(app.handler); + var server = await io.serve(handler, 'localhost', 8080); + + print('Serving at http://${server.address.host}:${server.port}'); +} diff --git a/lib/format.dart b/lib/format.dart new file mode 100644 index 0000000..ccb3f33 --- /dev/null +++ b/lib/format.dart @@ -0,0 +1,107 @@ +/// A library to export metrics in the Prometheus text representation. +library format; + +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/src/double_format.dart'; + +/// Content-type for text version 0.0.4. +const contentType = 'text/plain; version=0.0.4; charset=utf-8'; + +/// Write out the text version 0.0.4 of the given [MetricFamilySamples]. +void write004( + StringSink sink, Iterable metricFamilySamples) { + // See http://prometheus.io/docs/instrumenting/exposition_formats/ + // for the output format specification + for (var metricFamilySample in metricFamilySamples) { + sink.write('# HELP '); + sink.write(metricFamilySample.name); + sink.write(' '); + _writeEscapedHelp(sink, metricFamilySample.help); + sink.write('\n'); + + sink.write('# TYPE '); + sink.write(metricFamilySample.name); + sink.write(' '); + _writeMetricType(sink, metricFamilySample.type); + sink.write('\n'); + + for (var sample in metricFamilySample.samples) { + sink.write(sample.name); + if (sample.labelNames.isNotEmpty) { + sink.write('{'); + for (var i = 0; i < sample.labelNames.length; ++i) { + sink.write(sample.labelNames[i]); + sink.write('="'); + _writeEscapedLabelValue(sink, sample.labelValues[i]); + sink.write('\",'); + } + sink.write('}'); + } + sink.write(' '); + sink.write(formatDouble(sample.value)); + // TODO: Write Timestamp + sink.writeln(); + } + } +} + +void _writeMetricType(StringSink sink, MetricType type) { + switch (type) { + case MetricType.counter: + sink.write('counter'); + break; + case MetricType.gauge: + sink.write('gauge'); + break; + case MetricType.summary: + sink.write('summary'); + break; + case MetricType.histogram: + sink.write('histogram'); + break; + case MetricType.untyped: + sink.write('untyped'); + break; + } +} + +const _codeUnitLineFeed = 10; // \n +const _codeUnitBackslash = 92; // \ +const _codeUnitDoubleQuotes = 34; // " + +void _writeEscapedHelp(StringSink sink, String help) { + for (var i = 0; i < help.length; ++i) { + var c = help.codeUnitAt(i); + switch (c) { + case _codeUnitBackslash: + sink.write('\\\\'); + break; + case _codeUnitLineFeed: + sink.write('\\n'); + break; + default: + sink.writeCharCode(c); + break; + } + } +} + +void _writeEscapedLabelValue(StringSink sink, String labelValue) { + for (var i = 0; i < labelValue.length; ++i) { + var c = labelValue.codeUnitAt(i); + switch (c) { + case _codeUnitBackslash: + sink.write('\\\\'); + break; + case _codeUnitDoubleQuotes: + sink.write('\\"'); + break; + case _codeUnitLineFeed: + sink.write('\\n'); + break; + default: + sink.writeCharCode(c); + break; + } + } +} diff --git a/lib/prometheus_client.dart b/lib/prometheus_client.dart new file mode 100644 index 0000000..fab5819 --- /dev/null +++ b/lib/prometheus_client.dart @@ -0,0 +1,23 @@ +/// A library containing the core elements of the Prometheus client, like the +/// [CollectorRegistry] and different types of metrics like [Counter], [Gauge] +/// and [Histogram]. +library prometheus_client; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; + +import "package:collection/collection.dart"; +import 'package:prometheus_client/src/double_format.dart'; + +part 'src/prometheus_client/collector.dart'; + +part 'src/prometheus_client/counter.dart'; + +part 'src/prometheus_client/gauge.dart'; + +part 'src/prometheus_client/helper.dart'; + +part 'src/prometheus_client/histogram.dart'; + +part 'src/prometheus_client/simple_collector.dart'; diff --git a/lib/runtime_metrics.dart b/lib/runtime_metrics.dart new file mode 100644 index 0000000..78b039c --- /dev/null +++ b/lib/runtime_metrics.dart @@ -0,0 +1,44 @@ +/// A library exposing metrics of the Dart runtime. +library runtime_metrics; + +import 'dart:io'; +import 'package:prometheus_client/prometheus_client.dart'; + +// This is not the actual startup time of the process, but the time the first +// collector was created. Dart's lazy initialization of globals doesn't allow +// for a better timing... + +/// Collector for runtime metrics. Exposes the `dart_info` and +/// `process_resident_memory_bytes` metric. +class RuntimeCollector extends Collector { + static final _startupTime = + DateTime.now().millisecondsSinceEpoch / Duration.millisecondsPerSecond; + + @override + Iterable collect() sync* { + yield MetricFamilySamples("dart_info", MetricType.gauge, + "Information about the Dart environment.", [ + Sample("dart_info", const ["version"], [Platform.version], 1) + ]); + + yield MetricFamilySamples("process_resident_memory_bytes", MetricType.gauge, + "Resident memory size in bytes.", [ + Sample("process_resident_memory_bytes", const [], const [], + ProcessInfo.currentRss.toDouble()) + ]); + + yield MetricFamilySamples("process_start_time_seconds", MetricType.gauge, + "Start time of the process since unix epoch in seconds.", [ + Sample("process_start_time_seconds", const [], const [], + _startupTime.toDouble()) + ]); + } +} + +/// Register default metrics for the Dart runtime. If no [registry] is provided, +/// the [CollectorRegistry.defaultRegistry] is used. +void register([CollectorRegistry registry]) { + registry ??= CollectorRegistry.defaultRegistry; + + registry.register(RuntimeCollector()); +} diff --git a/lib/shelf_handler.dart b/lib/shelf_handler.dart new file mode 100644 index 0000000..7974b6f --- /dev/null +++ b/lib/shelf_handler.dart @@ -0,0 +1,22 @@ +/// A library containing a shelf handler that exposes metrics in the Prometheus +/// text format. +library shelf_handler; + +import 'package:shelf/shelf.dart' as shelf; +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/format.dart' as format; + +/// Create a shelf handler that returns the metrics in the prometheus text +/// representation. If no [registry] is provided, the +/// [CollectorRegistry.defaultRegistry] is used. +prometheusHandler([CollectorRegistry registry]) { + registry ??= CollectorRegistry.defaultRegistry; + + return (shelf.Request request) { + // TODO: Instead of using a StringBuffer we could directly stream to network + final buffer = StringBuffer(); + format.write004(buffer, registry.collectMetricFamilySamples()); + return shelf.Response.ok(buffer.toString(), + headers: {"Content-Type": format.contentType}); + }; +} diff --git a/lib/shelf_metrics.dart b/lib/shelf_metrics.dart new file mode 100644 index 0000000..dd641d9 --- /dev/null +++ b/lib/shelf_metrics.dart @@ -0,0 +1,41 @@ +/// A library to track request times for shelf servers. +library shelf_metrics; + +import 'package:shelf/shelf.dart' as shelf; +import 'package:prometheus_client/prometheus_client.dart'; + +/// Register default metrics for the shelf and returns a [shelf.Middleware] that +/// can be added to the [shelf.Pipeline]. If no [registry] is provided, the +/// [CollectorRegistry.defaultRegistry] is used. +shelf.Middleware register([CollectorRegistry registry]) { + final histogram = Histogram('http_request_duration_seconds', + 'A histogram of the HTTP request durations.', + labelNames: ['method', 'code']); + + registry ??= CollectorRegistry.defaultRegistry; + registry.register(histogram); + + return (innerHandler) { + return (request) { + var watch = Stopwatch()..start(); + + return Future.sync(() => innerHandler(request)).then((response) { + if (response != null) { + histogram.labels([request.method, '${response.statusCode}']).observe( + watch.elapsedMicroseconds / Duration.microsecondsPerSecond); + } + + return response; + }, onError: (error, StackTrace stackTrace) { + if (error is shelf.HijackException) { + throw error; + } + + histogram.labels([request.method, '000']).observe( + watch.elapsedMicroseconds / Duration.microsecondsPerSecond); + + throw error; + }); + }; + }; +} diff --git a/lib/src/double_format.dart b/lib/src/double_format.dart new file mode 100644 index 0000000..a6e71d0 --- /dev/null +++ b/lib/src/double_format.dart @@ -0,0 +1,15 @@ +library double_format; + +String formatDouble(double value) { + if (value.isInfinite) { + if (value.isNegative) { + return '-Inf'; + } else { + return '+Inf'; + } + } else if (value.isNaN) { + return 'NaN'; + } else { + return value.toString(); + } +} diff --git a/lib/src/prometheus_client/collector.dart b/lib/src/prometheus_client/collector.dart new file mode 100644 index 0000000..eb61b73 --- /dev/null +++ b/lib/src/prometheus_client/collector.dart @@ -0,0 +1,168 @@ +part of prometheus_client; + +/// Defines the different metric type supported by Prometheus. +enum MetricType { + /// [MetricType.counter] is a monotonically increasing counter. + counter, + + /// [MetricType.gauge] represents a value that can go up and down. + gauge, + + /// A [MetricType.summary] samples observations over sliding windows of time + /// and provides instantaneous insight into their distributions, frequencies, + /// and sums. + summary, + + /// [MetricType.histogram]s allow aggregatable distributions of events, such + /// as request latencies. + histogram, + + /// [MetricType.untyped] can be used for metrics that don't fit the other + /// types. + untyped +} + +/// A [Sample] represents a sampled value of a metric. +class Sample { + /// The [name] of the metric. + final String name; + + /// The unmodifiable list of label names corresponding to the [labelValues]. + /// Label values and name with the same index belong to each other. + final List labelNames; + + /// The unmodifiable list of label values corresponding to the [labelNames]. + /// Label values and name with the same index belong to each other. + final List labelValues; + + /// The sampled value of the metric. + final double value; + + /// Constructs a new sample with [name], [labelNames], [labelValues] as well + /// as the sampled [value]. + /// [labelNames] and [labelValues] can be empty lists. + Sample( + this.name, List labelNames, List labelValues, this.value) + : labelNames = List.unmodifiable(labelNames), + labelValues = List.unmodifiable(labelValues); + + @override + String toString() { + final buffer = StringBuffer(); + buffer.write(name); + buffer.write(' ('); + for (var i = 0; i < labelNames.length; ++i) { + if (i > 0) { + buffer.write(' '); + } + buffer.write(labelNames[i]); + buffer.write('='); + buffer.write(labelValues[i]); + } + buffer.write(') '); + buffer.write(value); + return buffer.toString(); + } +} + +/// A [MetricFamilySamples] groups all samples of a metric family. +class MetricFamilySamples { + /// The [name] of the metric. + final String name; + + /// The [type] of the metric. + final MetricType type; + + /// The [help] text of the metric. + final String help; + + /// The unmodifiable list of [samples] belonging the this metric family. + final List samples; + + /// Constructs a new metric family with a [name], [type], [help] text and + /// related [samples]. + MetricFamilySamples(this.name, this.type, this.help, List samples) + : samples = List.unmodifiable(samples); + + @override + String toString() => + '$name ($type) $help [${samples.isEmpty ? '' : '\n'}${samples.join('\n')}]'; +} + +/// A [Collector] is registered at a [CollectorRegistry] and scraped for metrics. +/// A [Collector] can be registered at multiple [CollectorRegistry]s. +abstract class Collector { + /// [collect] all metrics and samples that are part of this [Collector]. + Iterable collect(); +} + +/// A [CollectorRegistry] is used to manage [Collector]s. +/// Own [CollectorRegistry] instances can be created, but a [defaultRegistry] is +/// also provided. +class CollectorRegistry { + /// The default [CollectorRegistry] that can be used to register [Collector]s. + /// Most of the time, the [defaultRegistry] is sufficient. + static final defaultRegistry = CollectorRegistry(); + + final _collectorsToNames = Map>(); + final _namesToCollectors = Map(); + + /// Register a [Collector] with the [CollectorRegistry]. + /// Does nothing if the [collector] is already registered. + register(Collector collector) { + final collectorNames = _collectNames(collector); + + for (var name in collectorNames) { + if (_namesToCollectors.containsKey(name)) { + throw ArgumentError( + 'Collector already registered that provides name: ' + name); + } + } + + for (var name in collectorNames) { + _namesToCollectors[name] = collector; + } + + _collectorsToNames[collector] = collectorNames; + } + + /// Unregister a [Collector] from the [CollectorRegistry]. + unregister(Collector collector) { + final collectorNames = _collectorsToNames.remove(collector); + + for (var name in collectorNames) { + _namesToCollectors.remove(name); + } + } + + /// Collect all metrics and samples from the registered [Collector]s. + Iterable collectMetricFamilySamples() { + return _collectorsToNames.keys.map((c) => c.collect()).expand((m) => m); + } + + Set _collectNames(Collector collector) { + final metricFamilySamples = collector.collect(); + final metricNames = Set(); + + for (var metricFamily in metricFamilySamples) { + switch (metricFamily.type) { + case MetricType.summary: + metricNames.add(metricFamily.name + '_count'); + metricNames.add(metricFamily.name + '_sum'); + metricNames.add(metricFamily.name); + break; + case MetricType.histogram: + metricNames.add(metricFamily.name + '_count'); + metricNames.add(metricFamily.name + '_sum'); + metricNames.add(metricFamily.name + '_bucket'); + metricNames.add(metricFamily.name); + break; + default: + metricNames.add(metricFamily.name); + break; + } + } + + return metricNames; + } +} diff --git a/lib/src/prometheus_client/counter.dart b/lib/src/prometheus_client/counter.dart new file mode 100644 index 0000000..a5fd45e --- /dev/null +++ b/lib/src/prometheus_client/counter.dart @@ -0,0 +1,52 @@ +part of prometheus_client; + +/// [Counter] is a monotonically increasing counter. +class Counter extends _SimpleCollector { + /// Construct a new [Counter] with a [name], [help] text and optional + /// [labelNames]. + /// If [labelNames] are provided, use [labels(...)] to assign label values. + Counter(String name, String help, {List labelNames = const []}) + : super(name, help, labelNames: labelNames); + + /// Increment the [value] of the counter without labels by [amount]. + /// Increments by one, if no amount is provided. + void inc([double amount = 1]) { + _noLabelChild.inc(amount); + } + + /// Accesses the current value of the counter without labels. + double get value => _noLabelChild.value; + + @override + Iterable collect() sync* { + final samples = []; + _children.forEach((labelValues, child) => + samples.add(Sample(name, labelNames, labelValues, child._value))); + + yield MetricFamilySamples(name, MetricType.counter, help, samples); + } + + @override + CounterChild _createChild() => CounterChild._(); +} + +/// Defines a [CounterChild] of a [Counter] with assigned [labelValues]. +class CounterChild { + double _value = 0; + + CounterChild._(); + + /// Increment the [value] of the counter with labels by [amount]. + /// Increments by one, if no amount is provided. + void inc([double amount = 1]) { + if (amount < 0) { + throw ArgumentError.value( + amount, 'amount', 'Must be greater or equal to zero.'); + } + + _value += amount; + } + + /// Accesses the current value of the counter with labels. + double get value => _value; +} diff --git a/lib/src/prometheus_client/gauge.dart b/lib/src/prometheus_client/gauge.dart new file mode 100644 index 0000000..ae09209 --- /dev/null +++ b/lib/src/prometheus_client/gauge.dart @@ -0,0 +1,76 @@ +part of prometheus_client; + +/// A [Gauge] represents a value that can go up and down. +class Gauge extends _SimpleCollector { + /// Construct a new [Gauge] with a [name], [help] text and optional + /// [labelNames]. + /// If [labelNames] are provided, use [labels(...)] to assign label values. + Gauge(String name, String help, {List labelNames = const []}) + : super(name, help, labelNames: labelNames); + + /// Increment the [value] of the gauge without labels by [amount]. + /// Increments by one, if no amount is provided. + void inc([double amount = 1]) { + _noLabelChild.inc(amount); + } + + /// Decrement the [value] of the gauge without labels by [amount]. + /// Decrements by one, if no amount is provided. + void dec([double amount = 1]) { + _noLabelChild.dec(amount); + } + + /// Set the [value] of the gauge without labels to the current time as a unix + /// timestamp. + void setToCurrentTime() { + _noLabelChild.setToCurrentTime(); + } + + /// Accesses the current value of the gauge without labels. + double get value => _noLabelChild.value; + + /// Sets the current value of the gauge without labels. + set value(double v) => _noLabelChild.value = v; + + @override + Iterable collect() sync* { + final samples = []; + _children.forEach((labelValues, child) => + samples.add(Sample(name, labelNames, labelValues, child._value))); + + yield MetricFamilySamples(name, MetricType.gauge, help, samples); + } + + @override + GaugeChild _createChild() => GaugeChild._(); +} + +class GaugeChild { + double _value = 0; + + GaugeChild._(); + + /// Increment the [value] of the gauge with labels by [amount]. + /// Increments by one, if no amount is provided. + void inc([double amount = 1]) { + _value += amount; + } + + /// Decrement the [value] of the gauge with labels by [amount]. + /// Decrements by one, if no amount is provided. + void dec([double amount = 1]) { + _value -= amount; + } + + /// Set the [value] of the gauge with labels to the current time as a unix + /// timestamp. + void setToCurrentTime() { + _value = DateTime.now().millisecondsSinceEpoch.toDouble(); + } + + /// Accesses the current value of the gauge with labels. + double get value => _value; + + /// Sets the current value of the gauge with labels. + set value(double v) => _value = v; +} diff --git a/lib/src/prometheus_client/helper.dart b/lib/src/prometheus_client/helper.dart new file mode 100644 index 0000000..2baabc4 --- /dev/null +++ b/lib/src/prometheus_client/helper.dart @@ -0,0 +1,21 @@ +part of prometheus_client; + +final _metricNamePattern = RegExp('^[a-zA-Z_:][a-zA-Z0-9_:]*\$'); +final _labelNamePattern = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); +final _reservedMetricLabelNamePattern = RegExp('^__.*\$'); + +void _checkMetricName(String name) { + if (!_metricNamePattern.hasMatch(name)) { + throw ArgumentError.value(name, 'name', 'Invalid metric name'); + } +} + +void _checkMetricLabelName(String name) { + if (!_labelNamePattern.hasMatch(name)) { + throw ArgumentError.value(name, 'name', 'Invalid metric label name'); + } + if (_reservedMetricLabelNamePattern.hasMatch(name)) { + throw ArgumentError.value( + name, 'name', 'Invalid metric label name, reserved for internal use'); + } +} diff --git a/lib/src/prometheus_client/histogram.dart b/lib/src/prometheus_client/histogram.dart new file mode 100644 index 0000000..addd8c3 --- /dev/null +++ b/lib/src/prometheus_client/histogram.dart @@ -0,0 +1,198 @@ +part of prometheus_client; + +/// [Histogram] allows aggregatable distributions of events, such as request +/// latencies. +class Histogram extends _SimpleCollector { + /// The default upper bounds for histogram buckets. + static const defaultBuckets = [ + .005, + .01, + .025, + .05, + .075, + .1, + .25, + .5, + .75, + 1, + 2.5, + 5, + 7.5, + 10 + ]; + + /// The upper bounds of the buckets. + final List buckets; + + /// Construct a new [Histogram] with a [name], [help] text, optional + /// [labelNames] and optional upper bounds for the [buckets]. + /// If [labelNames] are provided, use [labels(...)] to assign label values. + /// [buckets] have to be sorted in ascending order. If no buckets are provided + /// the [defaultBuckets] are used instead. + Histogram(String name, String help, + {List labelNames = const [], + List buckets = defaultBuckets}) + : buckets = _sanitizeBuckets(buckets), + super(name, help, labelNames: labelNames) { + if (labelNames.contains('le')) { + throw ArgumentError.value(labelNames, 'labelNames', + '"le" is a reseved label name for a histogram.'); + } + } + + /// Construct a new [Histogram] with a [name], [help] text, and optional + /// [labelNames]. The [count] buckets are linear distributed starting at + /// [start] with a distance of [width]. + /// If [labelNames] are provided, use [labels(...)] to assign label values. + Histogram.linear( + String name, String help, double start, double width, int count, + {List labelNames = const []}) + : this(name, help, + labelNames: labelNames, + buckets: _generateLinearBuckets(start, width, count)); + + /// Construct a new [Histogram] with a [name], [help] text, and optional + /// [labelNames]. The [count] buckets are exponential distributed starting at + /// [start] with a distance growing exponentially by [factor]. + /// If [labelNames] are provided, use [labels(...)] to assign label values. + Histogram.exponential( + String name, String help, double start, double factor, int count, + {List labelNames = const []}) + : this(name, help, + labelNames: labelNames, + buckets: _generateExponentialBuckets(start, factor, count)); + + /// Observe a new value [v] and store it in the corresponding buckets of a + /// histogram without labels. + void observe(double v) { + _noLabelChild.observe(v); + } + + /// Observe the duration of [callback] and store it in the corresponding + /// buckets of a histogram without labels. + T observeDurationSync(T callback()) { + return _noLabelChild.observeDurationSync(callback); + } + + /// Observe the duration of the [Future] [f] and store it in the corresponding + /// buckets of a histogram without labels. + Future observeDuration(Future f) { + return _noLabelChild.observeDuration(f); + } + + /// Access the values in the buckets of a histogram without labels. + List get bucketValues => _noLabelChild.bucketValues; + + /// Access the count of elements in a histogram without labels. + double get count => _noLabelChild.count; + + /// Access the total sum of the elements in a histogram without labels. + double get sum => _noLabelChild.sum; + + @override + Iterable collect() sync* { + final samples = []; + + _children.forEach((labelValues, child) { + final labelNamesWithLe = List.of(labelNames)..add('le'); + + for (var i = 0; i < buckets.length; ++i) { + samples.add(Sample( + name + '_bucket', + labelNamesWithLe, + List.of(labelValues)..add(formatDouble(buckets[i])), + child._bucketValues[i])); + } + + samples + .add(Sample(name + '_count', labelNames, labelValues, child.count)); + samples.add(Sample(name + '_sum', labelNames, labelValues, child.sum)); + }); + + yield MetricFamilySamples(name, MetricType.histogram, help, samples); + } + + @override + HistogramChild _createChild() => HistogramChild._(buckets); + + static List _sanitizeBuckets(List buckets) { + if (buckets.isEmpty) { + throw ArgumentError.value( + buckets, 'buckets', 'Histogram must have at least one bucket.'); + } + buckets.reduce((l, r) { + if (l >= r) { + throw ArgumentError.value(buckets, 'buckets', + 'Histogram buckets must be in increasing order.'); + } + return r; + }); + + if (buckets[buckets.length - 1].isFinite) { + buckets = List.of(buckets); + buckets.add(double.infinity); + } + + return List.of(buckets); + } + + static List _generateLinearBuckets( + double start, double width, int count) => + List.generate(count, (i) => start + i * width); + + static List _generateExponentialBuckets( + double start, double factor, int count) => + List.generate(count, (i) => start * math.pow(factor, i)); +} + +/// Defines a [HistogramChild] of a [Histogram] with assigned [labelValues]. +class HistogramChild { + final List buckets; + final List _bucketValues; + double _sum = 0; + + HistogramChild._(this.buckets) + : _bucketValues = List.filled(buckets.length, 0.0); + + /// Observe a new value [v] and store it in the corresponding buckets of a + /// histogram with labels. + void observe(double v) { + for (var i = 0; i < buckets.length; ++i) { + if (v <= buckets[i]) { + _bucketValues[i]++; + } + } + _sum += v; + } + + /// Observe the duration of [callback] and store it in the corresponding + /// buckets of a histogram with labels. + T observeDurationSync(T callback()) { + final stopwatch = Stopwatch()..start(); + try { + return callback(); + } finally { + observe(stopwatch.elapsedMicroseconds / Duration.microsecondsPerSecond); + } + } + + /// Observe the duration of the [Future] [f] and store it in the corresponding + /// buckets of a histogram with labels. + Future observeDuration(Future f) async { + final stopwatch = Stopwatch()..start(); + try { + return await f; + } finally { + observe(stopwatch.elapsedMicroseconds / Duration.microsecondsPerSecond); + } + } + + /// Access the values in the buckets of a histogram with labels. + List get bucketValues => List.of(_bucketValues); + + /// Access the count of elements in a histogram with labels. + double get count => _bucketValues.last; + + /// Access the total sum of the elements in a histogram with labels. + double get sum => _sum; +} diff --git a/lib/src/prometheus_client/simple_collector.dart b/lib/src/prometheus_client/simple_collector.dart new file mode 100644 index 0000000..f695f89 --- /dev/null +++ b/lib/src/prometheus_client/simple_collector.dart @@ -0,0 +1,69 @@ +part of prometheus_client; + +abstract class _SimpleCollector extends Collector { + static const _eq = ListEquality(); + + /// The [name] of the metric. + final String name; + + /// The [help] text of the metric. + final String help; + + /// The unmodifiable list of [labelNames] assigned to this metric. + final List labelNames; + final _children = HashMap, Child>( + equals: _eq.equals, hashCode: _eq.hash, isValidKey: _eq.isValidKey); + + _SimpleCollector(this.name, this.help, {List labelNames = const []}) + : labelNames = List.unmodifiable(labelNames) { + _checkMetricName(name); + labelNames.forEach(_checkMetricLabelName); + _initializeNoLabelChild(); + } + + Child _createChild(); + + /// Register the [Collector] at a [registry]. If no [registry] is provided, the + /// [CollectorRegistry.defaultRegistry] is used. + void register([CollectorRegistry registry]) { + registry ??= CollectorRegistry.defaultRegistry; + + registry.register(this); + } + + /// Create a [Child] metric and assign the [labelValues]. + /// The size of the [labelValues] has to match the [labelNames] of the metric. + Child labels(List labelValues) { + if (labelValues.length != labelNames.length) { + throw ArgumentError.value( + labelValues, "labelValues", "Lenght must match label names."); + } + + return _children.putIfAbsent(List.unmodifiable(labelValues), _createChild); + } + + /// Remove a [Child] metric based on it's label values. + void remove(List labelValues) { + _children.remove(labelValues); + } + + /// Remove all [Child] metrics. + void clear() { + _children.clear(); + _initializeNoLabelChild(); + } + + Child get _noLabelChild { + if (labelNames.isNotEmpty) { + throw StateError('Metric has labels, set label values via labels(...).'); + } + + return labels(const []); + } + + void _initializeNoLabelChild() { + if (labelNames.isEmpty) { + labels(const []); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..ca6c2c3 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,17 @@ +name: prometheus_client +description: Dart implementation of the Prometheus client library providing metrics and a shelf integration. +version: 0.1.0 +homepage: https://github.com/Fox32/prometheus_client +author: Oliver Sand + +environment: + sdk: '>=2.5.0 <3.0.0' + +dependencies: + shelf: ^0.7.5 + collection: ^1.14.12 + +dev_dependencies: + pedantic: ^1.8.0 + test: ^1.6.0 + shelf_router: ^0.7.1 diff --git a/test/format_test.dart b/test/format_test.dart new file mode 100644 index 0000000..8f65b64 --- /dev/null +++ b/test/format_test.dart @@ -0,0 +1,133 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/format.dart'; +import 'package:test/test.dart'; + +void main() { + group('Format v0.0.4', () { + test('Should output metric help', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', []) + ]); + + expect(output, contains('# HELP my_metric This is a help text.\n')); + }); + + test('Should escape metric help', () { + final output = writeToString([ + MetricFamilySamples('my_metric', MetricType.gauge, + 'This is a help text\nwith multiple lines.', []) + ]); + + expect( + output, + contains( + '# HELP my_metric This is a help text\\nwith multiple lines.\n')); + }); + + test('Should output metric type', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', []) + ]); + + expect(output, contains('# TYPE my_metric gauge\n')); + }); + + test('Should output metric sample without labels', () { + final output = writeToString([ + MetricFamilySamples('my_metric', MetricType.gauge, + 'This is a help text.', [Sample('my_metric', [], [], 1.0)]) + ]); + + expect(output, contains('my_metric 1.0\n')); + }); + + test('Should output metric sample with one label', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', [ + Sample('my_metric', ['label'], ['value'], 1.0) + ]) + ]); + + expect(output, contains('my_metric{label="value",} 1.0\n')); + }); + + test('Should output metric sample with multiple labels', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', [ + Sample('my_metric', ['label1', 'label2'], ['value1', 'value2'], 1.0) + ]) + ]); + + expect(output, + contains('my_metric{label1="value1",label2="value2",} 1.0\n')); + }); + + test('Should escape metric sample label value', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', [ + Sample('my_metric', ['message'], + ['This is a "test" \\ with multiple\nlines'], 1.0) + ]) + ]); + + expect( + output, + contains( + 'my_metric{message="This is a \\"test\\" \\\\ with multiple\\nlines",} 1.0\n')); + }); + + test('Should output metric with multiple samples', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', [ + Sample('my_metric_sum', [], [], 5.0), + Sample('my_metric_total', [], [], 2.0), + ]) + ]); + + expect(output, contains('my_metric_sum 5.0\nmy_metric_total 2.0\n')); + }); + + test('Should handle special sample values', () { + final output = writeToString([ + MetricFamilySamples( + 'my_metric', MetricType.gauge, 'This is a help text.', [ + Sample('my_metric_inf', [], [], double.infinity), + Sample('my_metric_ninf', [], [], double.negativeInfinity), + Sample('my_metric_nan', [], [], double.nan), + ]) + ]); + + expect( + output, + contains( + 'my_metric_inf +Inf\nmy_metric_ninf -Inf\nmy_metric_nan NaN\n')); + }); + + test('Should output multiple metrics', () { + final output = writeToString([ + MetricFamilySamples('my_metric1', MetricType.gauge, + 'This is a help text.', [Sample('my_metric', [], [], 1.0)]), + MetricFamilySamples('my_metric2', MetricType.counter, + 'This is a help text.', [Sample('my_metric', [], [], 1.0)]), + ]); + + expect( + output, + contains( + '# HELP my_metric1 This is a help text.\n# TYPE my_metric1 gauge\nmy_metric 1.0\n' + '# HELP my_metric2 This is a help text.\n# TYPE my_metric2 counter\nmy_metric 1.0\n')); + }); + }); +} + +String writeToString(List metricFamilySamples) { + final stringBuffer = StringBuffer(); + write004(stringBuffer, metricFamilySamples); + return stringBuffer.toString(); +} diff --git a/test/prometheus_client_collector_test.dart b/test/prometheus_client_collector_test.dart new file mode 100644 index 0000000..30222d8 --- /dev/null +++ b/test/prometheus_client_collector_test.dart @@ -0,0 +1,78 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Collector', () { + test('Should register collector', () { + final collectorRegistry = CollectorRegistry(); + collectorRegistry.register(Gauge('my_metric', 'Help!')); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect(metricFamilySamples, equals(['my_metric'])); + }); + + test('Should register multiple collector', () { + final collectorRegistry = CollectorRegistry(); + collectorRegistry.register(Gauge('my_metric', 'Help!')); + collectorRegistry.register(Gauge('my_other_metric', 'Help!')); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect( + metricFamilySamples, containsAll(['my_metric', 'my_other_metric'])); + }); + + test('Should not register collector with conflicting name', () { + final collectorRegistry = CollectorRegistry(); + collectorRegistry.register(Gauge('my_metric', 'Help!')); + + expect(() => collectorRegistry.register(Histogram('my_metric', 'Help!')), + throwsArgumentError); + }); + + test('Should unregister collector', () { + final collectorRegistry = CollectorRegistry(); + final gauge = Gauge('my_metric', 'Help!'); + collectorRegistry.register(gauge); + collectorRegistry.register(Gauge('my_other_metric', 'Help!')); + + collectorRegistry.unregister(gauge); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect(metricFamilySamples, equals(['my_other_metric'])); + }); + + test('Should re-register collector', () { + final collectorRegistry = CollectorRegistry(); + final gauge = Gauge('my_metric', 'Help!'); + collectorRegistry.register(gauge); + collectorRegistry.register(Gauge('my_other_metric', 'Help!')); + + collectorRegistry.unregister(gauge); + collectorRegistry.register(gauge); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect( + metricFamilySamples, containsAll(['my_metric', 'my_other_metric'])); + }); + + test('Should collect samples', () { + final collectorRegistry = CollectorRegistry(); + collectorRegistry.register(Gauge('my_metric', 'Help!')..value = 42.0); + + final metricFamilySample = + collectorRegistry.collectMetricFamilySamples().first; + + expect(metricFamilySample.name, equals('my_metric')); + expect( + metricFamilySample.samples.map((s) => s.value).first, equals(42.0)); + }); + }); +} diff --git a/test/prometheus_client_counter_test.dart b/test/prometheus_client_counter_test.dart new file mode 100644 index 0000000..0d86dae --- /dev/null +++ b/test/prometheus_client_counter_test.dart @@ -0,0 +1,123 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Counter', () { + test('Should register counter at registry', () { + final collectorRegistry = CollectorRegistry(); + Counter('my_metric', 'Help!').register(collectorRegistry); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect(metricFamilySamples, contains('my_metric')); + }); + + test('Should initialize counter with 0', () { + final counter = Counter('my_metric', 'Help!'); + + expect(counter.value, equals(0.0)); + }); + + test('Should increment by one if no amount is specified', () { + final counter = Counter('my_metric', 'Help!'); + + counter.inc(); + + expect(counter.value, equals(1.0)); + }); + + test('Should increment by amount', () { + final counter = Counter('my_metric', 'Help!'); + + counter.inc(42.0); + + expect(counter.value, equals(42.0)); + }); + + test('Should not increment by negative amount', () { + final counter = Counter('my_metric', 'Help!'); + + expect(() => counter.inc(-42.0), throwsArgumentError); + }); + + test('Should not allow to set label values if no labels were specified', + () { + final counter = Counter('my_metric', 'Help!'); + + expect(() => counter.labels(['not_allowed']), throwsArgumentError); + }); + + test('Should collect samples for metric without labels', () { + final counter = Counter('my_metric', 'Help!'); + final sample = counter.collect().toList().expand((m) => m.samples).first; + + expect(sample.name, equals('my_metric')); + expect(sample.labelNames, isEmpty); + expect(sample.labelValues, isEmpty); + expect(sample.value, equals(0.0)); + }); + + test('Should get child for specified labels', () { + final counter = Counter('my_metric', 'Help!', labelNames: ['name']); + final child = counter.labels(['mine']); + + expect(child, isNotNull); + expect(child.value, 0.0); + }); + + test('Should fail if wrong amount of labels specified', () { + final counter = + Counter('my_metric', 'Help!', labelNames: ['name', 'state']); + + expect(() => counter.labels(['mine']), throwsArgumentError); + }); + + test('Should fail if labels specified but used without labels', () { + final counter = Counter('my_metric', 'Help!', labelNames: ['name']); + + expect(() => counter.inc(), throwsStateError); + }); + + test('Should collect samples for metric with labels', () { + final counter = Counter('my_metric', 'Help!', labelNames: ['name']); + counter.labels(['mine']); + final sample = counter.collect().toList().expand((m) => m.samples).first; + + expect(sample.name, equals('my_metric')); + expect(sample.labelNames, equals(['name'])); + expect(sample.labelValues, equals(['mine'])); + expect(sample.value, equals(0.0)); + }); + + test('Should remove a child', () { + final counter = Counter('my_metric', 'Help!', labelNames: ['name']); + counter.labels(['yours']); + counter.labels(['mine']); + counter.remove(['mine']); + final labelValues = counter + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, containsAll(['yours'])); + }); + + test('Should clear all children', () { + final counter = Counter('my_metric', 'Help!', labelNames: ['name']); + counter.labels(['yours']); + counter.labels(['mine']); + counter.clear(); + final labelValues = counter + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, isEmpty); + }); + }); +} diff --git a/test/prometheus_client_gauge_test.dart b/test/prometheus_client_gauge_test.dart new file mode 100644 index 0000000..9b0d976 --- /dev/null +++ b/test/prometheus_client_gauge_test.dart @@ -0,0 +1,149 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Gauge', () { + test('Should register gauge at registry', () { + final collectorRegistry = CollectorRegistry(); + Gauge('my_metric', 'Help!').register(collectorRegistry); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect(metricFamilySamples, contains('my_metric')); + }); + + test('Should initialize gauge with 0', () { + final gauge = Gauge('my_metric', 'Help!'); + + expect(gauge.value, equals(0.0)); + }); + + test('Should increment by one if no amount is specified', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.inc(); + + expect(gauge.value, equals(1.0)); + }); + + test('Should increment by amount', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.inc(42.0); + + expect(gauge.value, equals(42.0)); + }); + + test('Should decrement by one if no amount is specified', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.dec(); + + expect(gauge.value, equals(-1.0)); + }); + + test('Should decrement by amount', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.dec(42.0); + + expect(gauge.value, equals(-42.0)); + }); + + test('Should set to value', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.value = 42.0; + + expect(gauge.value, equals(42.0)); + }); + + test('Should set to current time', () { + final gauge = Gauge('my_metric', 'Help!'); + + gauge.setToCurrentTime(); + + expect(gauge.value, + closeTo(DateTime.now().millisecondsSinceEpoch.toDouble(), 1000)); + }); + + test('Should not allow to set label values if no labels were specified', + () { + final gauge = Gauge('my_metric', 'Help!'); + + expect(() => gauge.labels(['not_allowed']), throwsArgumentError); + }); + + test('Should collect samples for metric without labels', () { + final gauge = Gauge('my_metric', 'Help!'); + final sample = gauge.collect().toList().expand((m) => m.samples).first; + + expect(sample.name, equals('my_metric')); + expect(sample.labelNames, isEmpty); + expect(sample.labelValues, isEmpty); + expect(sample.value, equals(0.0)); + }); + + test('Should get child for specified labels', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name']); + final child = gauge.labels(['mine']); + + expect(child, isNotNull); + expect(child.value, 0.0); + }); + + test('Should fail if wrong amount of labels specified', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name', 'state']); + + expect(() => gauge.labels(['mine']), throwsArgumentError); + }); + + test('Should fail if labels specified but used without labels', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name']); + + expect(() => gauge.inc(), throwsStateError); + }); + + test('Should collect samples for metric with labels', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name']); + gauge.labels(['mine']); + final sample = gauge.collect().toList().expand((m) => m.samples).first; + + expect(sample.name, equals('my_metric')); + expect(sample.labelNames, equals(['name'])); + expect(sample.labelValues, equals(['mine'])); + expect(sample.value, equals(0.0)); + }); + + test('Should remove a child', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name']); + gauge.labels(['yours']); + gauge.labels(['mine']); + gauge.remove(['mine']); + final labelValues = gauge + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, containsAll(['yours'])); + }); + + test('Should clear all children', () { + final gauge = Gauge('my_metric', 'Help!', labelNames: ['name']); + gauge.labels(['yours']); + gauge.labels(['mine']); + gauge.clear(); + final labelValues = gauge + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, isEmpty); + }); + }); +} diff --git a/test/prometheus_client_helper_test.dart b/test/prometheus_client_helper_test.dart new file mode 100644 index 0000000..8f09e9b --- /dev/null +++ b/test/prometheus_client_helper_test.dart @@ -0,0 +1,21 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Helper', () { + test('Should validate metric name', () { + expect(Gauge('my_metric', 'Help!'), isNotNull); + expect(() => Gauge('my metric', 'Help!'), throwsArgumentError); + expect(() => Gauge('99metrics', 'Help!'), throwsArgumentError); + }); + + test('Should validate label names', () { + expect(Gauge('my_metric', 'Help!', labelNames: ['some', 'labels']), + isNotNull); + expect(() => Gauge('my_metric', 'Help!', labelNames: ['__internal']), + throwsArgumentError); + expect(() => Gauge('my_metric', 'Help!', labelNames: ['not allowed']), + throwsArgumentError); + }); + }); +} diff --git a/test/prometheus_client_histogram_test.dart b/test/prometheus_client_histogram_test.dart new file mode 100644 index 0000000..207449c --- /dev/null +++ b/test/prometheus_client_histogram_test.dart @@ -0,0 +1,247 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('Histogram', () { + test('Should register histogram at registry', () { + final collectorRegistry = CollectorRegistry(); + Histogram('my_metric', 'Help!').register(collectorRegistry); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples().map((m) => m.name); + + expect(metricFamilySamples, contains('my_metric')); + }); + + test('Should fail if labels contain "le"', () { + expect(() => Histogram('my_metric', 'Help!', labelNames: ['le']), + throwsArgumentError); + }); + + test('Should initialize histogram with custom buckets', () { + final buckets = [0.25, 0.5, 1.0]; + final counter = Histogram('my_metric', 'Help!', buckets: buckets); + + expect(counter.buckets, equals([0.25, 0.5, 1.0, double.infinity])); + }); + + test( + 'Should initialize histogram with custom buckets that already contain +Inf', + () { + final buckets = [0.25, 0.5, 1.0, double.infinity]; + final counter = Histogram('my_metric', 'Help!', buckets: buckets); + + expect(counter.buckets, equals(buckets)); + }); + + test('Should fail if custom buckets have wrong order', () { + final buckets = [0.25, 1.0, 0.5]; + expect(() => Histogram('my_metric', 'Help!', buckets: buckets), + throwsArgumentError); + }); + + test('Should initialize histogram with linear buckets', () { + final counter = Histogram.linear('my_metric', 'Help!', 1.0, 1.0, 10); + + expect( + counter.buckets, + equals([ + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + double.infinity + ])); + }); + + test('Should initialize histogram with exponential buckets', () { + final counter = Histogram.exponential('my_metric', 'Help!', 1.0, 2.0, 10); + + expect( + counter.buckets, + equals([ + 1.0, + 2.0, + 4.0, + 8.0, + 16.0, + 32.0, + 64.0, + 128.0, + 256.0, + 512.0, + double.infinity + ])); + }); + + test('Should initialize histogram with 0', () { + final histogram = Histogram('my_metric', 'Help!'); + + expect(histogram.sum, equals(0.0)); + expect(histogram.count, equals(0.0)); + expect( + histogram.bucketValues, + equals([ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ])); + }); + + test('Should observe values and update histogram', () { + final histogram = + Histogram('my_metric', 'Help!', buckets: [0.25, 0.5, 1.0]); + + histogram.observe(0.75); + histogram.observe(0.25); + histogram.observe(10.0); + + expect(histogram.sum, equals(11.0)); + expect(histogram.count, equals(3.0)); + expect(histogram.bucketValues, equals([1.0, 1.0, 2.0, 3.0])); + }); + + test('Should observe duration of callback', () { + final histogram = + Histogram('my_metric', 'Help!', buckets: [0.25, 0.5, 1.0]); + + histogram.observeDurationSync(() => {}); + + expect(histogram.sum, greaterThan(0.0)); + expect(histogram.count, equals(1.0)); + expect(histogram.bucketValues, equals([1.0, 1.0, 1.0, 1.0])); + }); + + test('Should observe duration of future', () async { + final histogram = + Histogram('my_metric', 'Help!', buckets: [0.25, 0.5, 1.0]); + + await histogram + .observeDuration(Future.delayed(Duration(milliseconds: 300))); + + expect(histogram.sum, greaterThan(0.3)); + expect(histogram.count, equals(1.0)); + expect(histogram.bucketValues, equals([0.0, 1.0, 1.0, 1.0])); + }); + + test('Should not allow to set label values if no labels were specified', + () { + final histogram = Histogram('my_metric', 'Help!'); + + expect(() => histogram.labels(['not_allowed']), throwsArgumentError); + }); + + test('Should collect samples for metric without labels', () { + final histogram = + Histogram('my_metric', 'Help!', buckets: [0.25, 0.5, 1.0]); + final samples = histogram.collect().toList().expand((m) => m.samples); + final sampleSum = samples.firstWhere((s) => s.name == 'my_metric_sum'); + final sampleCount = + samples.firstWhere((s) => s.name == 'my_metric_count'); + final sampleBuckets = samples.where((s) => s.name == 'my_metric_bucket'); + + expect(sampleSum.labelNames, isEmpty); + expect(sampleSum.labelValues, isEmpty); + expect(sampleSum.value, equals(0.0)); + + expect(sampleCount.labelNames, isEmpty); + expect(sampleCount.labelValues, isEmpty); + expect(sampleCount.value, equals(0.0)); + + expect(sampleBuckets, hasLength(4)); + }); + + test('Should get child for specified labels', () { + final histogram = Histogram('my_metric', 'Help!', + labelNames: ['name'], buckets: [0.25, 0.5, 1.0]); + final child = histogram.labels(['mine']); + + expect(child, isNotNull); + expect(child.sum, equals(0.0)); + expect(child.count, equals(0.0)); + expect(child.bucketValues, equals([0.0, 0.0, 0.0, 0.0])); + }); + + test('Should fail if wrong amount of labels specified', () { + final histogram = + Histogram('my_metric', 'Help!', labelNames: ['name', 'state']); + + expect(() => histogram.labels(['mine']), throwsArgumentError); + }); + + test('Should fail if labels specified but used without labels', () { + final histogram = Histogram('my_metric', 'Help!', labelNames: ['name']); + + expect(() => histogram.observe(1.0), throwsStateError); + }); + + test('Should collect samples for metric with labels', () { + final histogram = Histogram('my_metric', 'Help!', + labelNames: ['name'], buckets: [0.25, 0.5, 1.0]); + histogram.labels(['mine']); + final samples = histogram.collect().toList().expand((m) => m.samples); + final sampleSum = samples.firstWhere((s) => s.name == 'my_metric_sum'); + final sampleCount = + samples.firstWhere((s) => s.name == 'my_metric_count'); + final sampleBuckets = samples.where((s) => s.name == 'my_metric_bucket'); + + expect(sampleSum.labelNames, equals(['name'])); + expect(sampleSum.labelValues, equals(['mine'])); + expect(sampleSum.value, equals(0.0)); + + expect(sampleCount.labelNames, equals(['name'])); + expect(sampleCount.labelValues, equals(['mine'])); + expect(sampleCount.value, equals(0.0)); + + expect(sampleBuckets, hasLength(4)); + }); + + test('Should remove a child', () { + final histogram = Histogram('my_metric', 'Help!', labelNames: ['name']); + histogram.labels(['yours']); + histogram.labels(['mine']); + histogram.remove(['mine']); + final labelValues = histogram + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, containsAll(['yours'])); + }); + + test('Should clear all children', () { + final histogram = Histogram('my_metric', 'Help!', labelNames: ['name']); + histogram.labels(['yours']); + histogram.labels(['mine']); + histogram.clear(); + final labelValues = histogram + .collect() + .toList() + .expand((m) => m.samples) + .map((s) => s.labelValues) + .expand((l) => l); + + expect(labelValues, isEmpty); + }); + }); +} diff --git a/test/runtime_metrics_test.dart b/test/runtime_metrics_test.dart new file mode 100644 index 0000000..50c9e76 --- /dev/null +++ b/test/runtime_metrics_test.dart @@ -0,0 +1,72 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/runtime_metrics.dart' as runtime_metrics; +import 'package:test/test.dart'; + +void main() { + group('Runtime Metrics', () { + test('Should output dart_info metric', () { + final collectorRegistry = CollectorRegistry(); + runtime_metrics.register(collectorRegistry); + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples(); + final dartInfoMetric = + metricFamilySamples.where((m) => m.name == 'dart_info').first; + + expect(dartInfoMetric.name, equals('dart_info')); + expect(dartInfoMetric.help, isNotEmpty); + expect(dartInfoMetric.type, equals(MetricType.gauge)); + expect(dartInfoMetric.samples, hasLength(1)); + + final dartInfoSample = dartInfoMetric.samples.first; + + expect(dartInfoSample.name, equals('dart_info')); + expect(dartInfoSample.labelNames, equals(['version'])); + expect(dartInfoSample.labelValues, isNotEmpty); + expect(dartInfoSample.value, equals(1.0)); + }); + + test('Should output process_resident_memory_bytes metric', () { + final collectorRegistry = CollectorRegistry(); + runtime_metrics.register(collectorRegistry); + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples(); + final dartInfoMetric = metricFamilySamples + .where((m) => m.name == 'process_resident_memory_bytes') + .first; + + expect(dartInfoMetric.name, equals('process_resident_memory_bytes')); + expect(dartInfoMetric.help, isNotEmpty); + expect(dartInfoMetric.type, equals(MetricType.gauge)); + expect(dartInfoMetric.samples, hasLength(1)); + + final dartInfoSample = dartInfoMetric.samples.first; + + expect(dartInfoSample.name, equals('process_resident_memory_bytes')); + expect(dartInfoSample.labelNames, isEmpty); + expect(dartInfoSample.labelValues, isEmpty); + expect(dartInfoSample.value, greaterThan(0)); + }); + + test('Should output process_start_time_seconds metric', () { + final collectorRegistry = CollectorRegistry(); + runtime_metrics.register(collectorRegistry); + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples(); + final dartInfoMetric = metricFamilySamples + .where((m) => m.name == 'process_start_time_seconds') + .first; + + expect(dartInfoMetric.name, equals('process_start_time_seconds')); + expect(dartInfoMetric.help, isNotEmpty); + expect(dartInfoMetric.type, equals(MetricType.gauge)); + expect(dartInfoMetric.samples, hasLength(1)); + + final dartInfoSample = dartInfoMetric.samples.first; + + expect(dartInfoSample.name, equals('process_start_time_seconds')); + expect(dartInfoSample.labelNames, isEmpty); + expect(dartInfoSample.labelValues, isEmpty); + expect(dartInfoSample.value, greaterThan(0)); + }); + }); +} diff --git a/test/shelf_handler_test.dart b/test/shelf_handler_test.dart new file mode 100644 index 0000000..2509a31 --- /dev/null +++ b/test/shelf_handler_test.dart @@ -0,0 +1,26 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/shelf_handler.dart' as shelf_handler; +import 'package:test/test.dart'; + +void main() { + group('Shelf Handler', () { + test('Should output metrics', () async { + final collectorRegistry = CollectorRegistry(); + Gauge('my_metric', 'Help text') + ..register(collectorRegistry) + ..inc(); + + final handler = shelf_handler.prometheusHandler(collectorRegistry); + final response = handler(null); + + expect(response.statusCode, equals(200)); + expect( + response.headers, + containsPair( + 'content-type', 'text/plain; version=0.0.4; charset=utf-8')); + + final body = await response.readAsString(); + expect(body, contains('my_metric')); + }); + }); +} diff --git a/test/shelf_metrics_test.dart b/test/shelf_metrics_test.dart new file mode 100644 index 0000000..0b8bef6 --- /dev/null +++ b/test/shelf_metrics_test.dart @@ -0,0 +1,33 @@ +import 'package:prometheus_client/prometheus_client.dart'; +import 'package:prometheus_client/shelf_metrics.dart' as shelf_metrics; +import 'package:shelf/shelf.dart' as shelf; +import 'package:test/test.dart'; + +void main() { + group('Shelf Metrics', () { + test('Should observe handler duration', () async { + final collectorRegistry = CollectorRegistry(); + final middleware = shelf_metrics.register(collectorRegistry); + final handler = middleware((e) => Future.delayed( + Duration(milliseconds: 500), () => shelf.Response.ok('OK'))); + + await handler( + shelf.Request('GET', Uri.tryParse('http://example.com/test'))); + + final metricFamilySamples = + collectorRegistry.collectMetricFamilySamples(); + final metric = metricFamilySamples + .firstWhere((s) => s.name == 'http_request_duration_seconds'); + + expect(metric.name, equals('http_request_duration_seconds')); + expect(metric.help, isNotEmpty); + expect(metric.type, equals(MetricType.histogram)); + expect(metric.samples, isNotEmpty); + + final sample = metric.samples + .firstWhere((s) => s.name == 'http_request_duration_seconds_count'); + + expect(sample.value, equals(1)); + }); + }); +}