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 : }
|