LCOV - code coverage report
Current view: top level - src/metrics/export/prometheus - prometheus_exporter.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 90.0 % 80 72
Test Date: 2025-11-15 13:23:01 Functions: - 0 0

            Line data    Source code
       1              : // Licensed under the Apache License, Version 2.0
       2              : // Copyright 2025, Michael Bushe, All rights reserved.
       3              : 
       4              : import 'dart:async';
       5              : 
       6              : import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart'
       7              :     show OTelLog;
       8              : import '../../data/metric.dart';
       9              : import '../../data/metric_data.dart';
      10              : import '../../data/metric_point.dart';
      11              : import '../../metric_exporter.dart';
      12              : 
      13              : /// PrometheusExporter exports metrics in Prometheus format.
      14              : /// This can be exposed via an HTTP endpoint or written to a file.
      15              : /// This could be used on Dart server but not Flutter clients since
      16              : /// Prometheus is a pull model and expects stable http servers and
      17              : /// a Flutter client typically can't provide that.
      18              : /// Dartastic.io offers Prometheus for OTel by forwarding the OTLP data
      19              : /// to use Prometheus.  To forward OTLP to your own Prometheus backend you would
      20              : /// configure an OTel collector similar to the following:
      21              : /// receivers:
      22              : //   otlp:
      23              : //     protocols:
      24              : //       grpc:
      25              : //         endpoint: 0.0.0.0:4317
      26              : //
      27              : // processors:
      28              : //   batch:
      29              : //     timeout: 10s
      30              : //
      31              : // exporters:
      32              : //   prometheus:
      33              : //     endpoint: "0.0.0.0:8889"  # Endpoint for Prometheus to scrape
      34              : //     namespace: "flutter_apps"
      35              : //     const_labels:
      36              : //       source: "mobile_clients"
      37              : //
      38              : // service:
      39              : //   pipelines:
      40              : //     metrics:
      41              : //       receivers: [otlp]
      42              : //       processors: [batch]
      43              : //       exporters: [prometheus]
      44              : class PrometheusExporter implements MetricExporter {
      45              :   bool _shutdown = false;
      46              : 
      47              :   /// The last generated Prometheus text exposition format data.
      48              :   String _lastExportData = '';
      49              : 
      50              :   /// Creates a new PrometheusExporter.
      51            1 :   PrometheusExporter();
      52              : 
      53              :   /// Gets the latest Prometheus exposition format data.
      54            2 :   String get prometheusData => _lastExportData;
      55              : 
      56            1 :   @override
      57              :   Future<bool> export(MetricData data) async {
      58            1 :     if (_shutdown) {
      59            1 :       if (OTelLog.isLogExport()) {
      60            0 :         OTelLog.logExport('PrometheusExporter: Cannot export after shutdown');
      61              :       }
      62              :       return false;
      63              :     }
      64              : 
      65            2 :     if (data.metrics.isEmpty) {
      66            1 :       if (OTelLog.isLogExport()) {
      67            0 :         OTelLog.logExport('PrometheusExporter: No metrics to export');
      68              :       }
      69              :       return true;
      70              :     }
      71              : 
      72              :     try {
      73            1 :       if (OTelLog.isLogExport()) {
      74            0 :         OTelLog.logExport(
      75            0 :             'PrometheusExporter: Exporting ${data.metrics.length} metrics');
      76              :       }
      77              : 
      78              :       // Convert metrics to Prometheus format
      79            2 :       _lastExportData = _toPrometheusFormat(data);
      80              : 
      81            1 :       if (OTelLog.isLogExport()) {
      82            0 :         OTelLog.logExport('PrometheusExporter: Export successful');
      83              :       }
      84              :       return true;
      85              :     } catch (e) {
      86            0 :       if (OTelLog.isLogExport()) {
      87            0 :         OTelLog.logExport('PrometheusExporter: Export failed: $e');
      88              :       }
      89              :       return false;
      90              :     }
      91              :   }
      92              : 
      93              :   /// Converts metric data to Prometheus exposition format.
      94            1 :   String _toPrometheusFormat(MetricData data) {
      95            1 :     final buffer = StringBuffer();
      96              : 
      97            2 :     for (final metric in data.metrics) {
      98              :       // Add HELP comment
      99            1 :       if (metric.description != null) {
     100            1 :         buffer.writeln(
     101            5 :             '# HELP ${_sanitizeName(metric.name)} ${_sanitizeComment(metric.description!)}');
     102              :       }
     103              : 
     104              :       // Add TYPE comment
     105            1 :       buffer.writeln(
     106            4 :           '# TYPE ${_sanitizeName(metric.name)} ${_getPrometheusType(metric)}');
     107              : 
     108              :       // Add metric data points
     109            2 :       for (final point in metric.points) {
     110            1 :         _writeMetricPoint(buffer, metric, point);
     111              :       }
     112              : 
     113              :       // Empty line between metrics
     114            1 :       buffer.writeln();
     115              :     }
     116              : 
     117            1 :     return buffer.toString();
     118              :   }
     119              : 
     120              :   /// Writes a metric point in Prometheus format.
     121            1 :   void _writeMetricPoint(
     122              :       StringBuffer buffer, Metric metric, MetricPoint<dynamic> point) {
     123            2 :     final metricName = _sanitizeName(metric.name);
     124              : 
     125              :     // Add labels
     126            3 :     final labels = _formatLabels(point.attributes.toMap());
     127              : 
     128            2 :     if (point.value is HistogramValue) {
     129              :       // Histogram metrics require special handling
     130            1 :       final histogram = point.value as HistogramValue;
     131              : 
     132              :       // Write sum
     133            3 :       buffer.writeln('${metricName}_sum$labels ${histogram.sum}');
     134              : 
     135              :       // Write count
     136            3 :       buffer.writeln('${metricName}_count$labels ${histogram.count}');
     137              : 
     138              :       // Write buckets
     139            4 :       for (int i = 0; i < histogram.boundaries.length; i++) {
     140            2 :         final boundary = histogram.boundaries[i];
     141            2 :         final count = histogram.bucketCounts[i];
     142            1 :         buffer.writeln(
     143            4 :             '${metricName}_bucket{${_formatLabelsWithLe(point.attributes.toMap(), boundary)}} $count');
     144              :       }
     145              : 
     146              :       // Add +Inf bucket
     147            1 :       buffer.writeln(
     148            5 :           '${metricName}_bucket{${_formatLabelsWithLe(point.attributes.toMap(), double.infinity)}} ${histogram.count}');
     149              :     } else {
     150              :       // Simple metrics (counters, gauges)
     151            3 :       buffer.writeln('$metricName$labels ${point.value}');
     152              :     }
     153              :   }
     154              : 
     155              :   /// Gets the Prometheus metric type from an OTel metric.
     156            1 :   String _getPrometheusType(Metric metric) {
     157            2 :     if (metric.points.isNotEmpty &&
     158            4 :         metric.points.first.value is HistogramValue) {
     159              :       return 'histogram';
     160            2 :     } else if (metric.type == MetricType.sum) {
     161              :       return 'counter';
     162              :     } else {
     163              :       return 'gauge';
     164              :     }
     165              :   }
     166              : 
     167              :   /// Formats attributes as Prometheus labels.
     168            1 :   String _formatLabels(Map<String, dynamic> attributes) {
     169            1 :     if (attributes.isEmpty) {
     170              :       return '{}'; // Return empty braces for metrics without attributes
     171              :     }
     172              : 
     173            3 :     final labelPairs = attributes.entries.map((entry) {
     174            5 :       return '${_sanitizeName(entry.key)}="${_sanitizeValue(entry.value)}"';
     175            1 :     }).join(',');
     176              : 
     177            1 :     return '{$labelPairs}';
     178              :   }
     179              : 
     180              :   /// Formats attributes with an added 'le' label for histogram buckets.
     181            1 :   String _formatLabelsWithLe(Map<String, dynamic> attributes, double le) {
     182            1 :     final newAttributes = Map<String, dynamic>.from(attributes);
     183            1 :     if (le == double.infinity) {
     184            1 :       newAttributes['le'] = '+Inf';
     185            2 :     } else if (le == le.truncateToDouble()) {
     186              :       // If the number is an integer (no decimal component),
     187              :       // format it without the decimal point
     188            3 :       newAttributes['le'] = le.toInt().toString();
     189              :     } else {
     190            0 :       newAttributes['le'] = le.toString();
     191              :     }
     192              : 
     193            3 :     final labelPairs = newAttributes.entries.map((entry) {
     194            5 :       return '${_sanitizeName(entry.key)}="${_sanitizeValue(entry.value)}"';
     195            1 :     }).join(',');
     196              : 
     197              :     return labelPairs;
     198              :   }
     199              : 
     200              :   /// Sanitizes a metric or label name.
     201            1 :   String _sanitizeName(String name) {
     202              :     // Replace invalid characters with underscores
     203              :     // Valid characters in Prometheus are: [a-zA-Z0-9_]
     204            2 :     return name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
     205              :   }
     206              : 
     207              :   /// Sanitizes a comment for HELP.
     208            1 :   String _sanitizeComment(String comment) {
     209              :     // Escape backslashes and newlines
     210            2 :     return comment.replaceAll(r'\', r'\\').replaceAll('\n', '\\n');
     211              :   }
     212              : 
     213              :   /// Sanitizes a label value.
     214            1 :   String _sanitizeValue(dynamic value) {
     215              :     // Handle AttributeValue objects by extracting the raw value
     216            2 :     if (value.toString().startsWith('AttributeValue(') &&
     217            2 :         value.toString().endsWith(')')) {
     218              :       // Extract the value inside AttributeValue(...)
     219              :       final rawValue = value
     220            1 :           .toString()
     221            5 :           .substring('AttributeValue('.length, value.toString().length - 1);
     222              :       value = rawValue;
     223              :     }
     224              : 
     225              :     // Escape quotes, backslashes, and newlines
     226              :     return value
     227            1 :         .toString()
     228            1 :         .replaceAll(r'\', r'\\')
     229            1 :         .replaceAll('"', r'\"')
     230            1 :         .replaceAll('\n', '\\n');
     231              :   }
     232              : 
     233            1 :   @override
     234              :   Future<bool> forceFlush() async {
     235              :     // No-op for this exporter
     236              :     return true;
     237              :   }
     238              : 
     239            1 :   @override
     240              :   Future<bool> shutdown() async {
     241            1 :     _shutdown = true;
     242              :     return true;
     243              :   }
     244              : }
        

Generated by: LCOV version 2.0-1