master
go 819 lines 18.7 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package jobruntime
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "runtime/debug"
13 "strings"
14 "sync/atomic"
15 "time"
16
17 "github.com/netdata/netdata/go/plugins/logger"
18 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
19 "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
20 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
21 "github.com/netdata/netdata/go/plugins/plugin/framework/metricsaudit"
22 "github.com/netdata/netdata/go/plugins/plugin/framework/tickstate"
23 "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
24 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/oldmetrix"
25 )
26
27 func newCollectStatusChart(pluginName string) *collectorapi.Chart {
28 chart := &collectorapi.Chart{
29 Title: "Data Collection Status",
30 Units: "status",
31 Fam: pluginName,
32 Ctx: "netdata.plugin_data_collection_status",
33 Priority: 144000,
34 Dims: collectorapi.Dims{
35 {ID: "success"},
36 {ID: "failed"},
37 },
38 }
39 chart.SetCachedType("netdata")
40 return chart
41 }
42
43 func newCollectDurationChart(pluginName string) *collectorapi.Chart {
44 chart := &collectorapi.Chart{
45 Title: "Data Collection Duration",
46 Units: "ms",
47 Fam: pluginName,
48 Ctx: "netdata.plugin_data_collection_duration",
49 Priority: 145000,
50 Dims: collectorapi.Dims{
51 {ID: "duration"},
52 },
53 }
54 chart.SetCachedType("netdata")
55 return chart
56 }
57
58 type JobConfig struct {
59 PluginName string
60 Name string
61 ModuleName string
62 FullName string
63 Source string
64 Module collectorapi.CollectorV1
65 Labels map[string]string
66 Out io.Writer
67 UpdateEvery int
68 AutoDetectEvery int
69 Priority int
70 IsStock bool
71 Vnode vnodes.VirtualNode
72 AuditMode bool
73 AuditAnalyzer metricsaudit.Analyzer
74 FunctionOnly bool
75 }
76
77 func NewJob(cfg JobConfig) *Job {
78 var buf bytes.Buffer
79
80 if cfg.UpdateEvery == 0 {
81 cfg.UpdateEvery = 1
82 }
83
84 j := &Job{
85 AutoDetectEvery: cfg.AutoDetectEvery,
86 AutoDetectTries: infTries,
87
88 pluginName: cfg.PluginName,
89 name: cfg.Name,
90 moduleName: cfg.ModuleName,
91 fullName: cfg.FullName,
92 updateEvery: cfg.UpdateEvery,
93 priority: cfg.Priority,
94 isStock: cfg.IsStock,
95 functionOnly: cfg.FunctionOnly,
96 module: cfg.Module,
97 labels: cfg.Labels,
98 out: cfg.Out,
99 collectStatusChart: newCollectStatusChart(cfg.PluginName),
100 collectDurationChart: newCollectDurationChart(cfg.PluginName),
101 stopCtrl: newStopController(),
102 tick: make(chan int),
103 buf: &buf,
104 api: netdataapi.New(&buf),
105 vnode: cfg.Vnode,
106 updVnode: make(chan *vnodes.VirtualNode, 1),
107 auditMode: cfg.AuditMode,
108 auditAnalyzer: cfg.AuditAnalyzer,
109 }
110
111 log := logger.New().With(jobLoggerAttrs(j.ModuleName(), j.Name(), cfg.Source)...)
112
113 j.Logger = log
114 if j.module != nil {
115 j.module.GetBase().Logger = log
116 }
117
118 return j
119 }
120
121 // Job represents a job. It's a module wrapper.
122 type Job struct {
123 pluginName string
124 name string
125 moduleName string
126 fullName string
127
128 updateEvery int
129 AutoDetectEvery int
130 AutoDetectTries int
131 priority int
132 labels map[string]string
133
134 *logger.Logger
135
136 isStock bool
137 functionOnly bool
138
139 module collectorapi.CollectorV1
140
141 // running tracks whether the job's main loop is active (set in Start, cleared in Start's defer)
142 running atomic.Bool
143
144 initialized bool
145 panicked atomic.Bool
146
147 collectStatusChart *collectorapi.Chart
148 collectDurationChart *collectorapi.Chart
149 charts *collectorapi.Charts
150 tick chan int
151 out io.Writer
152 buf *bytes.Buffer
153 api *netdataapi.API
154
155 vnodeCreated bool
156 vnode vnodes.VirtualNode
157 updVnode chan *vnodes.VirtualNode
158
159 retries atomic.Int64
160 prevRun time.Time
161
162 stopCtrl stopController
163
164 // Metrics-audit mode support.
165 auditMode bool
166 auditAnalyzer metricsaudit.Analyzer
167 skipTracker tickstate.SkipTracker
168 }
169
170 type collectedMetrics struct {
171 intMetrics map[string]int64
172 floatMetrics map[string]float64 // not used, only v2 collectors will have float metrics
173 }
174
175 func (cm *collectedMetrics) getValue(id string) (float64, bool) {
176 if v, ok := cm.floatMetrics[id]; ok {
177 return v, true
178 }
179 v, ok := cm.intMetrics[id]
180 return float64(v), ok
181 }
182
183 // NetdataChartIDMaxLength is the chart ID max length. See RRD_ID_LENGTH_MAX in the netdata source code.
184 const NetdataChartIDMaxLength = 1200
185
186 // FullName returns job full name.
187 func (j *Job) FullName() string {
188 return j.fullName
189 }
190
191 // ModuleName returns job module name.
192 func (j *Job) ModuleName() string {
193 return j.moduleName
194 }
195
196 // Name returns job name.
197 func (j *Job) Name() string {
198 return j.name
199 }
200
201 // Panicked returns 'panicked' flag value.
202 func (j *Job) Panicked() bool {
203 return j.panicked.Load()
204 }
205
206 // AutoDetectionEvery returns value of AutoDetectEvery.
207 func (j *Job) AutoDetectionEvery() int {
208 return j.AutoDetectEvery
209 }
210
211 // RetryAutoDetection returns whether it is needed to retry autodetection.
212 func (j *Job) RetryAutoDetection() bool {
213 return retryAutoDetection(j.AutoDetectEvery, j.AutoDetectTries)
214 }
215
216 func (j *Job) Configuration() any {
217 return j.module.Configuration()
218 }
219
220 func (j *Job) Vnode() vnodes.VirtualNode {
221 return j.vnode
222 }
223
224 // AutoDetection invokes init, check and postCheck. It handles panic.
225 func (j *Job) AutoDetection() (err error) {
226 defer func() {
227 if r := recover(); r != nil {
228 err = fmt.Errorf("panic %v", r)
229 j.panicked.Store(true)
230 j.disableAutoDetection()
231
232 j.Errorf("PANIC %v", r)
233 if logger.Level.Enabled(slog.LevelDebug) {
234 j.Errorf("STACK: %s", debug.Stack())
235 }
236 }
237 if err != nil {
238 j.module.Cleanup(context.TODO())
239 }
240 }()
241
242 if j.isStock {
243 j.Mute()
244 }
245
246 if err = j.init(); err != nil {
247 j.Errorf("init failed: %v", err)
248 j.Unmute()
249 j.disableAutoDetection()
250 return err
251 }
252
253 if err = j.check(); err != nil {
254 j.Errorf("check failed: %v", err)
255 j.Unmute()
256 return err
257 }
258
259 j.Unmute()
260 j.Info("check success")
261
262 if err = j.postCheck(); err != nil {
263 j.Errorf("postCheck failed: %v", err)
264 j.disableAutoDetection()
265 return err
266 }
267
268 // Record job structure for metrics-audit mode after successful detection.
269 if j.auditMode && j.auditAnalyzer != nil && j.charts != nil {
270 j.auditAnalyzer.RecordJobStructure(j.name, j.moduleName, j.charts)
271 }
272
273 return nil
274 }
275
276 func (j *Job) UpdateVnode(vnode *vnodes.VirtualNode) {
277 if vnode == nil {
278 return
279 }
280 select {
281 case <-j.updVnode:
282 default:
283 }
284 j.updVnode <- vnode
285 }
286
287 // Tick Tick.
288 func (j *Job) Tick(clock int) {
289 enqueueTickWithSkipLog(j.tick, clock, j.functionOnly, j.updateEvery, int(j.retries.Load()), &j.skipTracker, j.Logger)
290 }
291
292 // IsRunning returns true if the job's main loop is currently running.
293 // This is safe to call from any goroutine.
294 func (j *Job) IsRunning() bool {
295 return j.running.Load()
296 }
297
298 // Module returns the underlying module instance.
299 // This allows function handlers to access the collector for querying data.
300 func (j *Job) Module() collectorapi.CollectorV1 {
301 return j.module
302 }
303
304 // Collector returns the underlying collector instance bound to this job.
305 func (j *Job) Collector() any {
306 return j.module
307 }
308
309 // IsFunctionOnly returns true if this job is function-only (no metrics collection).
310 func (j *Job) IsFunctionOnly() bool {
311 return j.functionOnly
312 }
313
314 // Start starts job main loop.
315 func (j *Job) Start() {
316 j.stopCtrl.markStarted()
317 j.running.Store(true)
318 if j.functionOnly {
319 j.Info("started in function-only mode")
320 } else {
321 j.Infof("started, data collection interval %ds", j.updateEvery)
322 }
323 defer func() {
324 j.running.Store(false)
325 j.stopCtrl.markStopped()
326 j.Info("stopped")
327 }()
328
329 LOOP:
330 for {
331 select {
332 case <-j.stopCtrl.stopCh:
333 break LOOP
334 case t := <-j.tick:
335 if !j.functionOnly && j.shouldCollect(t) {
336 markRunStartWithResumeLog(&j.skipTracker, j.Logger)
337
338 j.runOnce()
339
340 j.skipTracker.MarkRunStop(time.Now())
341 }
342 }
343 }
344 j.module.Cleanup(context.TODO())
345 j.Cleanup()
346 }
347
348 // Stop stops job main loop. It blocks until the job is stopped.
349 func (j *Job) Stop() {
350 j.stopCtrl.stopAndWait()
351 }
352
353 func (j *Job) shouldCollect(clock int) bool {
354 return shouldCollectWithPenalty(clock, j.updateEvery, int(j.retries.Load()))
355 }
356
357 func (j *Job) disableAutoDetection() {
358 disableAutoDetection(&j.AutoDetectEvery)
359 }
360
361 func (j *Job) Cleanup() {
362 j.buf.Reset()
363 if !collectorapi.ShouldObsoleteCharts() {
364 return
365 }
366
367 // Netdata automatically obsoletes vnode charts when no updates are sent.
368 // For virtual nodes with a stale label, we must not send anything:
369 // - Sending a HOST line would incorrectly mark the vnode as active.
370 isVnodeWithStaleConfig := j.vnode.Labels["_node_stale_after_seconds"] != ""
371
372 if !isVnodeWithStaleConfig {
373 if !j.vnodeCreated && j.vnode.GUID != "" {
374 j.sendVnodeHostInfo()
375 j.vnodeCreated = true
376 }
377 j.api.HOST(j.vnode.GUID)
378
379 if j.charts != nil {
380 for _, chart := range *j.charts {
381 if chart.IsCreated() {
382 chart.MarkRemove()
383 j.createChart(chart)
384 }
385 }
386 }
387 }
388
389 j.api.HOST("")
390
391 if j.collectStatusChart.IsCreated() {
392 j.collectStatusChart.MarkRemove()
393 j.createChart(j.collectStatusChart)
394 }
395 if j.collectDurationChart.IsCreated() {
396 j.collectDurationChart.MarkRemove()
397 j.createChart(j.collectDurationChart)
398 }
399
400 if j.buf.Len() > 0 {
401 _, _ = io.Copy(j.out, j.buf)
402 }
403 }
404
405 func (j *Job) init() error {
406 if j.initialized {
407 return nil
408 }
409
410 if err := j.module.Init(context.TODO()); err != nil {
411 return err
412 }
413
414 j.initialized = true
415
416 return nil
417 }
418
419 func (j *Job) check() error {
420 if err := j.module.Check(context.TODO()); err != nil {
421 consumeAutoDetectTry(&j.AutoDetectTries)
422 return err
423 }
424 return nil
425 }
426
427 func (j *Job) postCheck() error {
428 j.charts = j.module.Charts()
429 if j.charts == nil && !j.functionOnly {
430 j.Error("nil charts")
431 return errors.New("nil charts")
432 }
433 if j.charts != nil {
434 if err := collectorapi.CheckCharts(*j.charts...); err != nil {
435 j.Errorf("charts check: %v", err)
436 return err
437 }
438 }
439 return nil
440 }
441
442 func (j *Job) runOnce() {
443 defer j.ResetAllOnce()
444
445 curTime := time.Now()
446 sinceLastRun := calcSinceLastRun(curTime, j.prevRun)
447 j.prevRun = curTime
448
449 metrics := j.collect()
450
451 if j.panicked.Load() {
452 return
453 }
454
455 if j.processMetrics(metrics, curTime, sinceLastRun) {
456 j.retries.Store(0)
457 } else {
458 j.retries.Add(1)
459 }
460
461 _, _ = io.Copy(j.out, j.buf)
462 j.buf.Reset()
463 }
464
465 func (j *Job) collect() collectedMetrics {
466 j.panicked.Store(false)
467 defer func() {
468 if r := recover(); r != nil {
469 j.panicked.Store(true)
470 j.Errorf("PANIC: %v", r)
471 if logger.Level.Enabled(slog.LevelDebug) {
472 j.Errorf("STACK: %s", debug.Stack())
473 }
474 }
475 }()
476
477 var mx collectedMetrics
478 mx.intMetrics = j.module.Collect(context.TODO())
479
480 // Record collected metrics for metrics-audit mode.
481 // TODO: The analyzer only records intMetrics but ignores floatMetrics.
482 if j.auditMode && j.auditAnalyzer != nil && mx.intMetrics != nil {
483 j.auditAnalyzer.RecordCollection(j.name, j.moduleName, mx.intMetrics)
484 }
485
486 return mx
487 }
488
489 func (j *Job) processMetrics(mx collectedMetrics, startTime time.Time, sinceLastRun int) bool {
490 var createChart bool
491 if j.module.VirtualNode() == nil {
492 select {
493 case vnode := <-j.updVnode:
494 j.vnodeCreated = false
495 createChart = j.vnode.GUID != vnode.GUID
496 j.vnode = *vnode.Copy()
497 default:
498 }
499 }
500
501 if !j.vnodeCreated {
502 if j.vnode.GUID == "" {
503 if v := j.module.VirtualNode(); v != nil && v.GUID != "" && v.Hostname != "" {
504 j.vnode = *v
505 }
506 }
507 if j.vnode.GUID != "" {
508 j.sendVnodeHostInfo()
509 j.vnodeCreated = true
510 }
511 }
512
513 bufLenBeforeHost := j.buf.Len()
514 j.api.HOST(j.vnode.GUID)
515
516 elapsed := int64(durationTo(time.Since(startTime), time.Millisecond))
517
518 var i, updated, created int
519 for _, chart := range *j.charts {
520 if !chart.IsCreated() || createChart {
521 typeID := fmt.Sprintf("%s.%s", getChartType(chart, j), getChartID(chart))
522 if len(typeID) >= NetdataChartIDMaxLength {
523 j.Warningf("chart 'type.id' length (%d) >= max allowed (%d), the chart is ignored (%s)",
524 len(typeID), NetdataChartIDMaxLength, typeID)
525 chart.SetIgnored(true)
526 }
527 j.createChart(chart)
528 created++
529 }
530 if chart.IsRemoved() {
531 continue
532 }
533 (*j.charts)[i] = chart
534 i++
535 if len(mx.intMetrics)+len(mx.floatMetrics) == 0 || chart.Obsolete {
536 continue
537 }
538 if j.updateChart(chart, mx, sinceLastRun) {
539 updated++
540 }
541 }
542 *j.charts = (*j.charts)[:i]
543
544 if updated == 0 && created == 0 && j.vnode.GUID != "" {
545 j.buf.Truncate(bufLenBeforeHost)
546 }
547
548 j.api.HOST("")
549
550 if !j.collectStatusChart.IsCreated() || createChart {
551 j.collectStatusChart.ID = fmt.Sprintf("%s_%s_data_collection_status", cleanPluginName(j.pluginName), j.FullName())
552 j.createChart(j.collectStatusChart)
553 }
554
555 if !j.collectDurationChart.IsCreated() || createChart {
556 j.collectDurationChart.ID = fmt.Sprintf("%s_%s_data_collection_duration", cleanPluginName(j.pluginName), j.FullName())
557 j.createChart(j.collectDurationChart)
558 }
559
560 // Update analyzer with current chart structure for dynamic collectors.
561 if j.auditMode && j.auditAnalyzer != nil {
562 j.auditAnalyzer.UpdateJobStructure(j.name, j.moduleName, j.charts)
563 }
564
565 intMx := collectedMetrics{intMetrics: map[string]int64{"success": oldmetrix.Bool(updated > 0), "failed": oldmetrix.Bool(updated == 0)}}
566 j.updateChart(j.collectStatusChart, intMx, sinceLastRun)
567
568 if updated == 0 {
569 return false
570 }
571
572 intMx = collectedMetrics{intMetrics: map[string]int64{"duration": elapsed}}
573 j.updateChart(j.collectDurationChart, intMx, sinceLastRun)
574
575 return true
576 }
577
578 func (j *Job) sendVnodeHostInfo() {
579 info, err := chartemit.PrepareHostInfo(netdataapi.HostInfo{
580 GUID: j.vnode.GUID,
581 Hostname: j.vnode.Hostname,
582 Labels: j.vnode.Labels,
583 })
584 if err != nil {
585 j.Warningf("prepare vnode host info failed: %v", err)
586 return
587 }
588
589 j.vnode.Hostname = info.Hostname
590 j.vnode.Labels = info.Labels
591 j.api.HOSTINFO(info)
592 }
593
594 func (j *Job) createChart(chart *collectorapi.Chart) {
595 defer func() { chart.SetCreated(true) }()
596 if chart.IsIgnored() {
597 return
598 }
599
600 if chart.Priority == 0 {
601 chart.Priority = j.priority
602 j.priority++
603 }
604 updateEvery := j.updateEvery
605 if chart.UpdateEvery > 0 {
606 updateEvery = chart.UpdateEvery
607 }
608
609 j.api.CHART(netdataapi.ChartOpts{
610 TypeID: getChartType(chart, j),
611 ID: getChartID(chart),
612 Name: chart.OverID,
613 Title: chart.Title,
614 Units: chart.Units,
615 Family: chart.Fam,
616 Context: chart.Ctx,
617 ChartType: chart.Type.String(),
618 Priority: chart.Priority,
619 UpdateEvery: updateEvery,
620 Options: chart.Opts.String(),
621 Plugin: j.pluginName,
622 Module: j.moduleName,
623 })
624
625 if chart.Obsolete {
626 _ = j.api.EMPTYLINE()
627 return
628 }
629
630 seen := make(map[string]bool)
631 for _, l := range chart.Labels {
632 if l.Key != "" {
633 seen[l.Key] = true
634 ls := l.Source
635 // the default should be auto
636 // https://github.com/netdata/netdata/blob/cc2586de697702f86a3c34e60e23652dd4ddcb42/database/rrd.h#L205
637 if ls == 0 {
638 ls = collectorapi.LabelSourceAuto
639 }
640 j.api.CLABEL(l.Key, lblValueReplacer.Replace(l.Value), ls)
641 }
642 }
643 for k, v := range j.labels {
644 if !seen[k] {
645 j.api.CLABEL(k, lblValueReplacer.Replace(v), collectorapi.LabelSourceConf)
646 }
647 }
648 j.api.CLABEL("_collect_job", lblValueReplacer.Replace(j.Name()), collectorapi.LabelSourceAuto)
649 j.api.CLABELCOMMIT()
650
651 for _, dim := range chart.Dims {
652 j.api.DIMENSION(netdataapi.DimensionOpts{
653 ID: firstNotEmpty(dim.Name, dim.ID),
654 Name: dim.Name,
655 Algorithm: dim.Algo.String(),
656 Multiplier: handleZero(dim.Mul),
657 Divisor: handleZero(dim.Div),
658 Options: dim.DimOpts.String(),
659 })
660 }
661 for _, v := range chart.Vars {
662 name := firstNotEmpty(v.Name, v.ID)
663 j.api.VARIABLE(name, v.Value)
664 }
665 _ = j.api.EMPTYLINE()
666 }
667
668 func (j *Job) updateChart(chart *collectorapi.Chart, mx collectedMetrics, sinceLastRun int) bool {
669 if chart.IsIgnored() {
670 dims := chart.Dims[:0]
671 for _, dim := range chart.Dims {
672 if !dim.IsRemoved() {
673 dims = append(dims, dim)
674 }
675 }
676 chart.Dims = dims
677 return false
678 }
679
680 // Handle SkipGaps: check if any dimension has data
681 if chart.SkipGaps {
682 hasData := false
683 for _, dim := range chart.Dims {
684 if dim.IsRemoved() {
685 continue
686 }
687 if _, hasData = mx.getValue(dim.ID); hasData {
688 break
689 }
690 }
691 if !hasData {
692 // No dimensions have data - skip this chart entirely
693 return false
694 }
695 // At least one dimension has data - proceed with deltaTime=0
696 sinceLastRun = 0
697 } else if !chart.IsUpdated() {
698 sinceLastRun = 0
699 }
700
701 j.api.BEGIN(getChartType(chart, j), getChartID(chart), sinceLastRun)
702
703 var i, updated int
704 for _, dim := range chart.Dims {
705 if dim.IsRemoved() {
706 continue
707 }
708 chart.Dims[i] = dim
709 i++
710
711 name := firstNotEmpty(dim.Name, dim.ID)
712 v, ok := mx.getValue(dim.ID)
713 if !ok {
714 j.api.SETEMPTY(name)
715 continue
716 }
717 updated++
718 if dim.Float {
719 j.api.SETFLOAT(name, v)
720 } else {
721 j.api.SET(name, int64(v))
722 }
723 }
724
725 chart.Dims = chart.Dims[:i]
726
727 for _, vr := range chart.Vars {
728 if v, ok := mx.getValue(vr.ID); ok {
729 name := firstNotEmpty(vr.Name, vr.ID)
730 j.api.VARIABLE(name, v)
731 }
732 }
733
734 j.api.END()
735
736 chart.SetUpdated(updated > 0)
737 if chart.IsUpdated() {
738 chart.Retries = 0
739 } else {
740 chart.Retries++
741 }
742 return chart.IsUpdated()
743 }
744
745 func (j *Job) penalty() int {
746 return penaltyFromRetries(int(j.retries.Load()), j.updateEvery)
747 }
748
749 func getChartType(chart *collectorapi.Chart, j *Job) string {
750 if chart.CachedType() != "" {
751 return chart.CachedType()
752 }
753 if !chart.IDSep {
754 chart.SetCachedType(j.FullName())
755 } else if i := strings.IndexByte(chart.ID, '.'); i != -1 {
756 chart.SetCachedType(j.FullName() + "_" + chart.ID[:i])
757 } else {
758 chart.SetCachedType(j.FullName())
759 }
760 if chart.OverModule != "" {
761 cachedType := chart.CachedType()
762 if v, ok := strings.CutPrefix(cachedType, j.ModuleName()); ok {
763 chart.SetCachedType(chart.OverModule + v)
764 }
765 }
766 return chart.CachedType()
767 }
768
769 func getChartID(chart *collectorapi.Chart) string {
770 if chart.CachedID() != "" {
771 return chart.CachedID()
772 }
773 if !chart.IDSep {
774 return chart.ID
775 }
776 if i := strings.IndexByte(chart.ID, '.'); i != -1 {
777 chart.SetCachedID(chart.ID[i+1:])
778 } else {
779 chart.SetCachedID(chart.ID)
780 }
781 return chart.CachedID()
782 }
783
784 func calcSinceLastRun(curTime, prevRun time.Time) int {
785 if prevRun.IsZero() {
786 return 0
787 }
788 return int((curTime.UnixNano() - prevRun.UnixNano()) / 1000)
789 }
790
791 func durationTo(duration time.Duration, to time.Duration) int {
792 return int(int64(duration) / (int64(to) / int64(time.Nanosecond)))
793 }
794
795 func firstNotEmpty(val1, val2 string) string {
796 if val1 != "" {
797 return val1
798 }
799 return val2
800 }
801
802 func handleZero(v int) int {
803 if v == 0 {
804 return 1
805 }
806 return v
807 }
808
809 func cleanPluginName(name string) string {
810 r := strings.NewReplacer(" ", "_", ".", "_")
811 return r.Replace(name)
812 }
813
814 var lblValueReplacer = strings.NewReplacer(
815 "'", "",
816 "\n", " ",
817 "\r", " ",
818 "\x00", "",
819 )