Line data Source code
1 : // Copyright The OpenTelemetry Authors
2 : // SPDX-License-Identifier: Apache-2.0
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 2 : PrometheusExporter();
52 :
53 : /// Gets the latest Prometheus exposition format data.
54 4 : String get prometheusData => _lastExportData;
55 :
56 2 : @override
57 : Future<bool> export(MetricData data) async {
58 2 : if (_shutdown) {
59 2 : if (OTelLog.isLogExport()) {
60 1 : OTelLog.logExport('PrometheusExporter: Cannot export after shutdown');
61 : }
62 : return false;
63 : }
64 :
65 4 : if (data.metrics.isEmpty) {
66 2 : if (OTelLog.isLogExport()) {
67 1 : OTelLog.logExport('PrometheusExporter: No metrics to export');
68 : }
69 : return true;
70 : }
71 :
72 : try {
73 2 : if (OTelLog.isLogExport()) {
74 1 : OTelLog.logExport(
75 3 : 'PrometheusExporter: Exporting ${data.metrics.length} metrics',
76 : );
77 : }
78 :
79 : // Convert metrics to Prometheus format
80 4 : _lastExportData = _toPrometheusFormat(data);
81 :
82 2 : if (OTelLog.isLogExport()) {
83 1 : OTelLog.logExport('PrometheusExporter: Export successful');
84 : }
85 : return true;
86 : } catch (e) {
87 0 : if (OTelLog.isLogExport()) {
88 0 : OTelLog.logExport('PrometheusExporter: Export failed: $e');
89 : }
90 : return false;
91 : }
92 : }
93 :
94 : /// Converts metric data to Prometheus exposition format.
95 2 : String _toPrometheusFormat(MetricData data) {
96 2 : final buffer = StringBuffer();
97 :
98 4 : for (final metric in data.metrics) {
99 : // Add HELP comment
100 2 : if (metric.description != null) {
101 2 : buffer.writeln(
102 10 : '# HELP ${_sanitizeName(metric.name)} ${_sanitizeComment(metric.description!)}',
103 : );
104 : }
105 :
106 : // Add TYPE comment
107 2 : buffer.writeln(
108 8 : '# TYPE ${_sanitizeName(metric.name)} ${_getPrometheusType(metric)}',
109 : );
110 :
111 : // Add metric data points
112 4 : for (final point in metric.points) {
113 2 : _writeMetricPoint(buffer, metric, point);
114 : }
115 :
116 : // Empty line between metrics
117 2 : buffer.writeln();
118 : }
119 :
120 2 : return buffer.toString();
121 : }
122 :
123 : /// Writes a metric point in Prometheus format.
124 2 : void _writeMetricPoint(
125 : StringBuffer buffer,
126 : Metric metric,
127 : MetricPoint<dynamic> point,
128 : ) {
129 4 : final metricName = _sanitizeName(metric.name);
130 :
131 : // Add labels
132 6 : final labels = _formatLabels(point.attributes.toMap());
133 :
134 4 : if (point.value is HistogramValue) {
135 : // Histogram metrics require special handling
136 1 : final histogram = point.value as HistogramValue;
137 :
138 : // Write sum
139 3 : buffer.writeln('${metricName}_sum$labels ${histogram.sum}');
140 :
141 : // Write count
142 3 : buffer.writeln('${metricName}_count$labels ${histogram.count}');
143 :
144 : // Write buckets
145 4 : for (var i = 0; i < histogram.boundaries.length; i++) {
146 2 : final boundary = histogram.boundaries[i];
147 2 : final count = histogram.bucketCounts[i];
148 1 : buffer.writeln(
149 4 : '${metricName}_bucket{${_formatLabelsWithLe(point.attributes.toMap(), boundary)}} $count',
150 : );
151 : }
152 :
153 : // Add +Inf bucket
154 1 : buffer.writeln(
155 5 : '${metricName}_bucket{${_formatLabelsWithLe(point.attributes.toMap(), double.infinity)}} ${histogram.count}',
156 : );
157 : } else {
158 : // Simple metrics (counters, gauges)
159 6 : buffer.writeln('$metricName$labels ${point.value}');
160 : }
161 : }
162 :
163 : /// Gets the Prometheus metric type from an OTel metric.
164 2 : String _getPrometheusType(Metric metric) {
165 4 : if (metric.points.isNotEmpty &&
166 8 : metric.points.first.value is HistogramValue) {
167 : return 'histogram';
168 4 : } else if (metric.type == MetricType.sum) {
169 : return 'counter';
170 : } else {
171 : return 'gauge';
172 : }
173 : }
174 :
175 : /// Formats attributes as Prometheus labels.
176 2 : String _formatLabels(Map<String, dynamic> attributes) {
177 2 : if (attributes.isEmpty) {
178 : return '{}'; // Return empty braces for metrics without attributes
179 : }
180 :
181 6 : final labelPairs = attributes.entries.map((entry) {
182 10 : return '${_sanitizeName(entry.key)}="${_sanitizeValue(entry.value)}"';
183 2 : }).join(',');
184 :
185 2 : return '{$labelPairs}';
186 : }
187 :
188 : /// Formats attributes with an added 'le' label for histogram buckets.
189 1 : String _formatLabelsWithLe(Map<String, dynamic> attributes, double le) {
190 1 : final newAttributes = Map<String, dynamic>.from(attributes);
191 1 : if (le == double.infinity) {
192 1 : newAttributes['le'] = '+Inf';
193 2 : } else if (le == le.truncateToDouble()) {
194 : // If the number is an integer (no decimal component),
195 : // format it without the decimal point
196 3 : newAttributes['le'] = le.toInt().toString();
197 : } else {
198 0 : newAttributes['le'] = le.toString();
199 : }
200 :
201 3 : final labelPairs = newAttributes.entries.map((entry) {
202 5 : return '${_sanitizeName(entry.key)}="${_sanitizeValue(entry.value)}"';
203 1 : }).join(',');
204 :
205 : return labelPairs;
206 : }
207 :
208 : /// Sanitizes a metric or label name.
209 2 : String _sanitizeName(String name) {
210 : // Replace invalid characters with underscores
211 : // Valid characters in Prometheus are: [a-zA-Z0-9_]
212 4 : return name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
213 : }
214 :
215 : /// Sanitizes a comment for HELP.
216 2 : String _sanitizeComment(String comment) {
217 : // Escape backslashes and newlines
218 4 : return comment.replaceAll(r'\', r'\\').replaceAll('\n', '\\n');
219 : }
220 :
221 : /// Sanitizes a label value.
222 2 : String _sanitizeValue(dynamic value) {
223 : // Handle AttributeValue objects by extracting the raw value
224 4 : if (value.toString().startsWith('AttributeValue(') &&
225 4 : value.toString().endsWith(')')) {
226 : // Extract the value inside AttributeValue(...)
227 4 : final rawValue = value.toString().substring(
228 2 : 'AttributeValue('.length,
229 6 : value.toString().length - 1,
230 : );
231 : value = rawValue;
232 : }
233 :
234 : // Escape quotes, backslashes, and newlines
235 : return value
236 2 : .toString()
237 2 : .replaceAll(r'\', r'\\')
238 2 : .replaceAll('"', r'\"')
239 2 : .replaceAll('\n', '\\n');
240 : }
241 :
242 1 : @override
243 : Future<bool> forceFlush() async {
244 : // No-op for this exporter
245 : return true;
246 : }
247 :
248 2 : @override
249 : Future<bool> shutdown() async {
250 2 : _shutdown = true;
251 : return true;
252 : }
253 : }
|