1 package sinks
2
3 import (
4 "context"
5 "fmt"
6 "maps"
7 "net"
8 "net/http"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/cybertec-postgresql/pgwatch/v6/internal/log"
15 "github.com/cybertec-postgresql/pgwatch/v6/internal/metrics"
16 "github.com/prometheus/client_golang/prometheus"
17 "github.com/prometheus/client_golang/prometheus/promhttp"
18 "github.com/prometheus/common/model"
19 )
20
21 type PromMetricCache = map[string]map[string]metrics.MeasurementEnvelope
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44 type PrometheusWriter struct {
45 sync.RWMutex
46 logger log.Logger
47 ctx context.Context
48 gauges map[string]([]string)
49 Namespace string
50 Cache PromMetricCache
51
52
53 lastScrapeErrors prometheus.Gauge
54 totalScrapes prometheus.Counter
55 totalScrapeFailures prometheus.Counter
56 }
57
58 const promInstanceUpStateMetric = "instance_up"
59
60
61 const promCacheTTL = time.Minute * time.Duration(10)
62
63 func NewPrometheusWriter(ctx context.Context, connstr string) (promw *PrometheusWriter, err error) {
64 addr, namespace, found := strings.Cut(connstr, "/")
65 if !found || namespace == "" {
66 namespace = "pgwatch"
67 }
68 l := log.GetLogger(ctx).WithField("sink", "prometheus").WithField("address", addr)
69 ctx = log.WithLogger(ctx, l)
70
71 promw = &PrometheusWriter{
72 ctx: ctx,
73 logger: l,
74 Namespace: namespace,
75 Cache: make(PromMetricCache),
76 lastScrapeErrors: prometheus.NewGauge(prometheus.GaugeOpts{
77 Namespace: namespace,
78 Name: "exporter_last_scrape_errors",
79 Help: "Last scrape error count for all monitored hosts / metrics",
80 }),
81 totalScrapes: prometheus.NewCounter(prometheus.CounterOpts{
82 Namespace: namespace,
83 Name: "exporter_total_scrapes",
84 Help: "Total scrape attempts.",
85 }),
86 totalScrapeFailures: prometheus.NewCounter(prometheus.CounterOpts{
87 Namespace: namespace,
88 Name: "exporter_total_scrape_failures",
89 Help: "Number of errors while executing metric queries",
90 }),
91 }
92
93 if err = prometheus.Register(promw); err != nil {
94 return
95 }
96
97 promServer := &http.Server{
98 Addr: addr,
99 Handler: promhttp.HandlerFor(
100 prometheus.DefaultGatherer,
101 promhttp.HandlerOpts{
102 ErrorLog: promw,
103 ErrorHandling: promhttp.ContinueOnError,
104 },
105 ),
106 }
107
108 ln, err := net.Listen("tcp", promServer.Addr)
109 if err != nil {
110 return nil, err
111 }
112
113 go func() { log.GetLogger(ctx).Error(promServer.Serve(ln)) }()
114
115 l.Info(`measurements sink is activated`)
116 return
117 }
118
119
120 func (promw *PrometheusWriter) Println(v ...any) {
121 promw.logger.Errorln(v...)
122 }
123
124
125 func (promw *PrometheusWriter) DefineMetrics(metrics *metrics.Metrics) (err error) {
126 promw.Lock()
127 defer promw.Unlock()
128 promw.gauges = make(map[string]([]string))
129 for name, m := range metrics.MetricDefs {
130 promw.gauges[name] = m.Gauges
131 }
132 return nil
133 }
134
135
136 func (promw *PrometheusWriter) Write(msg metrics.MeasurementEnvelope) error {
137 if len(msg.Data) == 0 {
138 return nil
139 }
140 promw.AddCacheEntry(msg.DBName, msg.MetricName, msg)
141 return nil
142 }
143
144
145
146 func (promw *PrometheusWriter) SyncMetric(sourceName, metricName string, op SyncOp) error {
147 switch op {
148 case DeleteOp:
149 promw.PurgeCacheEntry(sourceName, metricName)
150 case AddOp:
151 promw.InitCacheEntry(sourceName)
152 }
153 return nil
154 }
155
156 var notSupportedMetrics = map[string]struct{}{
157 "change_events": {},
158 "pgbouncer_stats": {},
159 "pgbouncer_clients": {},
160 "pgpool_processes": {},
161 "pgpool_stats": {},
162 }
163
164 func (promw *PrometheusWriter) AddCacheEntry(dbUnique, metric string, msgArr metrics.MeasurementEnvelope) {
165 if _, ok := notSupportedMetrics[metric]; ok && msgArr.SourceKind != "prometheus" {
166 return
167 }
168 promw.Lock()
169 defer promw.Unlock()
170 if _, ok := promw.Cache[dbUnique]; !ok {
171 promw.Cache[dbUnique] = make(map[string]metrics.MeasurementEnvelope)
172 }
173 promw.Cache[dbUnique][metric] = msgArr
174 }
175
176 func (promw *PrometheusWriter) InitCacheEntry(dbUnique string) {
177 promw.Lock()
178 defer promw.Unlock()
179 if _, ok := promw.Cache[dbUnique]; !ok {
180 promw.Cache[dbUnique] = make(map[string]metrics.MeasurementEnvelope)
181 }
182 }
183
184 func (promw *PrometheusWriter) PurgeCacheEntry(dbUnique, metric string) {
185 promw.Lock()
186 defer promw.Unlock()
187 if metric == "" {
188 delete(promw.Cache, dbUnique)
189 return
190 }
191 delete(promw.Cache[dbUnique], metric)
192 }
193
194
195
196 func (promw *PrometheusWriter) Describe(_ chan<- *prometheus.Desc) {
197 }
198
199
200
201
202 func (promw *PrometheusWriter) Collect(ch chan<- prometheus.Metric) {
203 promw.totalScrapes.Add(1)
204 ch <- promw.totalScrapes
205
206 promw.RLock()
207 if len(promw.Cache) == 0 {
208 promw.RUnlock()
209 promw.logger.Warning("No dbs configured for monitoring. Check config")
210 ch <- promw.totalScrapeFailures
211 promw.lastScrapeErrors.Set(0)
212 ch <- promw.lastScrapeErrors
213 return
214 }
215 snapshot := promw.snapshotCache()
216 promw.RUnlock()
217
218 var rows int
219 var lastScrapeErrors float64
220
221 t1 := time.Now()
222 for _, metricsMessages := range snapshot {
223 for _, envelope := range metricsMessages {
224 written, errors := promw.WritePromMetrics(envelope, ch)
225 lastScrapeErrors += float64(errors)
226 rows += written
227 }
228 }
229 promw.logger.WithField("count", rows).WithField("elapsed", time.Since(t1)).Info("measurements written")
230 ch <- promw.totalScrapeFailures
231 promw.lastScrapeErrors.Set(lastScrapeErrors)
232 ch <- promw.lastScrapeErrors
233 }
234
235
236
237
238
239 func (promw *PrometheusWriter) snapshotCache() PromMetricCache {
240 snapshot := make(PromMetricCache, len(promw.Cache))
241 for db, metricMap := range promw.Cache {
242 snapshot[db] = maps.Clone(metricMap)
243 }
244 return snapshot
245 }
246
247
248
249
250
251
252
253
254
255 func (promw *PrometheusWriter) WritePromMetrics(msg metrics.MeasurementEnvelope, ch chan<- prometheus.Metric) (written int, errorCount int) {
256 if len(msg.Data) == 0 {
257 return
258 }
259
260 isPromSource := msg.SourceKind == "prometheus"
261
262 promw.RLock()
263 gauges := promw.gauges[msg.MetricName]
264 promw.RUnlock()
265
266
267
268 baseEpochTime := time.Unix(0, msg.Data.GetEpoch())
269 if baseEpochTime.Before(time.Now().Add(-promCacheTTL)) {
270 promw.logger.Debugf("Dropping metric %s:%s cache set due to staleness (>%v)...", msg.DBName, msg.MetricName, promCacheTTL)
271 promw.PurgeCacheEntry(msg.DBName, msg.MetricName)
272 return
273 }
274
275 seen := make(map[string]any)
276
277
278
279
280 type fieldMeta struct {
281 fqName string
282 valueType prometheus.ValueType
283 }
284 metaByField := make(map[string]fieldMeta)
285 type descKey struct {
286 fqName string
287 labelKeys string
288 }
289 descs := make(map[descKey]*prometheus.Desc)
290 fqNamePrefix := promw.Namespace + "_" + msg.MetricName + "_"
291
292 for _, measurement := range msg.Data {
293 var labels map[string]string
294 if msg.CustomTags != nil {
295 labels = maps.Clone(msg.CustomTags)
296 } else {
297 labels = make(map[string]string)
298 }
299 labels["dbname"] = msg.DBName
300 fields := make(map[string]float64)
301
302
303 rowEpochTime := baseEpochTime
304 for k, v := range measurement {
305 if k == metrics.EpochColumnName {
306 if isPromSource {
307 if ns, ok := v.(int64); ok && ns != 0 {
308 rowEpochTime = time.Unix(0, ns)
309 }
310 }
311 continue
312 }
313 if v == nil || v == "" {
314 continue
315 }
316
317 if tag, found := strings.CutPrefix(k, metrics.TagPrefix); found {
318 labels[tag] = fmt.Sprintf("%v", v)
319 continue
320 }
321 switch t := v.(type) {
322 case int:
323 fields[k] = float64(t)
324 case int32:
325 fields[k] = float64(t)
326 case int64:
327 fields[k] = float64(t)
328 case float32:
329 fields[k] = float64(t)
330 case float64:
331 fields[k] = t
332 case bool:
333 if t {
334 fields[k] = 1
335 } else {
336 fields[k] = 0
337 }
338 default:
339 promw.logger.Debugf("skipping scraping column %s of [%s:%s], unsupported datatype: %v", k, msg.DBName, msg.MetricName, t)
340 }
341 }
342
343
344 labelKeys := slices.Sorted(maps.Keys(labels))
345 labelValues := make([]string, len(labelKeys))
346 for i, k := range labelKeys {
347 labelValues[i] = labels[k]
348 }
349 joinedLabelValues := strings.Join(labelValues, "_")
350
351 joinedLabelKeys := strings.Join(labelKeys, string(model.SeparatorByte))
352
353 for field, value := range fields {
354 meta, ok := metaByField[field]
355 if !ok {
356 if isPromSource {
357
358
359
360 if field != msg.MetricName {
361 continue
362 }
363 meta = fieldMeta{fqName: field, valueType: prometheus.UntypedValue}
364 } else {
365 meta.valueType = prometheus.CounterValue
366 if msg.MetricName == promInstanceUpStateMetric ||
367 len(gauges) > 0 && (gauges[0] == "*" || slices.Contains(gauges, field)) {
368 meta.valueType = prometheus.GaugeValue
369 }
370 if msg.MetricName == promInstanceUpStateMetric {
371 meta.fqName = promw.Namespace + "_" + msg.MetricName
372 } else {
373 meta.fqName = fqNamePrefix + field
374 }
375 }
376 metaByField[field] = meta
377 }
378
379
380 identity := meta.fqName + "_" + joinedLabelValues
381 if _, dup := seen[identity]; dup {
382 promw.logger.
383 WithField("metric", msg.MetricName).
384 Warning("duplicate metric identity dropped, prefix differentiating string columns with tag_")
385 errorCount++
386 continue
387 }
388 seen[identity] = struct{}{}
389
390 dk := descKey{meta.fqName, joinedLabelKeys}
391 desc, ok := descs[dk]
392 if !ok {
393 desc = prometheus.NewDesc(meta.fqName, msg.MetricName, labelKeys, nil)
394 descs[dk] = desc
395 }
396 m, err := prometheus.NewConstMetric(desc, meta.valueType, value, labelValues...)
397 if err != nil {
398 promw.logger.Warningf("skipping metric %s of [%s:%s]: %v", meta.fqName, msg.DBName, msg.MetricName, err)
399 errorCount++
400 continue
401 }
402 ch <- prometheus.NewMetricWithTimestamp(rowEpochTime, m)
403 written++
404 }
405 }
406 return
407 }
408