// Package metrics bildet Zeitreihen fuer die Diagramme der Oberflaeche. // // Die wichtigste Regel des Pakets steht in einem Satz: **Eine Luecke ist keine // Null.** Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz — nicht null // Byte je Sekunde. Wer Luecken als Nullen zeichnet, erzeugt eine Kurve, die auf // den Boden faellt, sobald nachts nichts lief; die Anlage sieht dann aus, als // waere ihre Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. // // Deshalb traegt jeder Punkt ein HasValue. Die Oberflaeche zeichnet dort nichts, // statt eine Linie durch den Nullpunkt zu ziehen. package metrics import ( "errors" "fmt" "time" ) // TimeRange ist ein vorgegebener Auswertungszeitraum. type TimeRange string const ( // RangeLastHour umfasst die letzte Stunde. RangeLastHour TimeRange = "1h" // RangeLastDay umfasst die letzten 24 Stunden. RangeLastDay TimeRange = "24h" // RangeLastWeek umfasst die letzten sieben Tage. RangeLastWeek TimeRange = "7d" // RangeLastMonth umfasst die letzten 30 Tage. RangeLastMonth TimeRange = "30d" // RangeLastQuarter umfasst die letzten 90 Tage. RangeLastQuarter TimeRange = "90d" // RangeLastYear umfasst das letzte Jahr. RangeLastYear TimeRange = "1y" // RangeCustom ist ein frei gewaehlter Zeitraum. RangeCustom TimeRange = "custom" ) // ErrInvalidRange meldet einen unbrauchbaren Zeitraum. var ErrInvalidRange = errors.New("der auswertungszeitraum ist unbrauchbar") // Window ist ein aufgeloester Auswertungszeitraum. type Window struct { // Range ist der angeforderte Zeitraum. Range TimeRange `json:"range"` // From ist der Beginn in UTC. From time.Time `json:"from"` // To ist das Ende in UTC. To time.Time `json:"to"` // BucketWidth ist die Breite eines Zeitfensters. // // Sie folgt dem Zeitraum, nicht dem Geschmack: Bei einem Jahr in // Fuenfminutenschritten entstuenden ueber hunderttausend Punkte, die kein // Diagramm darstellen kann und kein Browser gern laedt. BucketWidth time.Duration `json:"bucket_width_seconds,omitempty"` } // BucketCount liefert die Zahl der Zeitfenster. // // Aufgerundet, nicht abgerundet. Der Unterschied trifft genau den Zeitraum, in // dem er am meisten schmerzt: Ein Jahr geteilt durch sieben Tage ergibt 52,14 // Fenster. Abgerundet fielen die letzten 1 bis 7 Tage aus der Auswertung — also // ausgerechnet die juengsten Ereignisse. Der Jahresverlauf zeigte dann alles bis // vorletzte Woche und nichts danach. // // Das letzte Fenster ist dadurch kuerzer als die uebrigen. Das ist der bessere // Tausch: Ein leicht schmaleres Fenster am Rand faellt niemandem auf, eine // fehlende Woche schon. func (window Window) BucketCount() int { if window.BucketWidth <= 0 { return 0 } windowDuration := window.To.Sub(window.From) fullBuckets := int(windowDuration / window.BucketWidth) if windowDuration%window.BucketWidth != 0 { fullBuckets++ } return fullBuckets } // maximumBucketCount begrenzt die Zahl der Punkte einer Reihe. // // Mehr Punkte als Bildpunkte in der Breite eines Diagramms bringen keinen // Erkenntnisgewinn, kosten aber Speicher, Bandbreite und Rechenzeit. const maximumBucketCount = 500 // ResolveWindow bildet aus einem Zeitraum ein Auswertungsfenster. // // Die Bucket-Breite ist bewusst fest je Zeitraum und nicht frei waehlbar: Eine // Kurve, deren Punktabstand der Aufrufer bestimmt, laesst sich zwischen zwei // Ansichten nicht vergleichen. func ResolveWindow(requestedRange TimeRange, customFrom time.Time, customTo time.Time, referenceTime time.Time) (Window, error) { endTime := referenceTime.UTC() switch requestedRange { case RangeLastHour: return Window{Range: requestedRange, From: endTime.Add(-time.Hour), To: endTime, BucketWidth: time.Minute}, nil case RangeLastDay: return Window{Range: requestedRange, From: endTime.Add(-24 * time.Hour), To: endTime, BucketWidth: 15 * time.Minute}, nil case RangeLastWeek: return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -7), To: endTime, BucketWidth: time.Hour}, nil case RangeLastMonth: return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -30), To: endTime, BucketWidth: 6 * time.Hour}, nil case RangeLastQuarter: return Window{Range: requestedRange, From: endTime.AddDate(0, 0, -90), To: endTime, BucketWidth: 24 * time.Hour}, nil case RangeLastYear: // AddDate statt einer festen Stundenzahl: Ein Jahr hat 365 oder 366 // Tage, und Zeitumstellungen verschieben die Grenzen zusaetzlich. return Window{Range: requestedRange, From: endTime.AddDate(-1, 0, 0), To: endTime, BucketWidth: 7 * 24 * time.Hour}, nil case RangeCustom: return resolveCustomWindow(customFrom, customTo) default: return Window{}, fmt.Errorf("%w: %q ist kein bekannter zeitraum", ErrInvalidRange, requestedRange) } } // resolveCustomWindow bildet ein frei gewaehltes Auswertungsfenster. func resolveCustomWindow(customFrom time.Time, customTo time.Time) (Window, error) { if customFrom.IsZero() || customTo.IsZero() { return Window{}, fmt.Errorf("%w: ein eigener zeitraum braucht anfang und ende", ErrInvalidRange) } if !customTo.After(customFrom) { return Window{}, fmt.Errorf("%w: das ende liegt nicht nach dem anfang", ErrInvalidRange) } windowDuration := customTo.Sub(customFrom) // Die Bucket-Breite ergibt sich aus der Laenge des Zeitraums. Sie wird auf // eine volle Minute aufgerundet, damit die Fenstergrenzen auf runden Zeiten // liegen — sonst wandert die Beschriftung eines Diagramms bei jedem Aufruf. bucketWidth := windowDuration / maximumBucketCount if bucketWidth < time.Minute { bucketWidth = time.Minute } else { bucketWidth = bucketWidth.Round(time.Minute) } return Window{ Range: RangeCustom, From: customFrom.UTC(), To: customTo.UTC(), BucketWidth: bucketWidth, }, nil } // DataPoint ist ein Punkt einer Zeitreihe. type DataPoint struct { // Timestamp ist der Beginn des Zeitfensters in UTC. Timestamp time.Time `json:"timestamp"` // Value ist der Wert; nur gueltig, wenn HasValue gesetzt ist. // // **Ohne omitempty.** Mit ihm verschwaende ein gemessener Wert von null aus // der Antwort — null Fehlschlaege, null geschriebene Bytes — und die // Oberflaeche laese ein undefined, obwohl HasValue true meldet. Ein Feld, // das je nach Wert da ist oder nicht, ist die unangenehmste Sorte // Schnittstelle: Sie funktioniert fast immer. Value float64 `json:"value"` // HasValue meldet, ob in diesem Zeitfenster ueberhaupt etwas gemessen wurde. // // Das Feld ist der Kern des Pakets. Ohne es liefert eine Zeitreihe fuer // jedes leere Fenster eine Null, und aus „nichts lief" wird „Leistung auf // null gefallen". HasValue bool `json:"has_value"` // SampleCount ist die Zahl der eingeflossenen Messungen. // // Sie steht am Punkt, damit sich ein Ausreisser einordnen laesst: Ein // Mittelwert aus einer Messung ist etwas anderes als einer aus hundert. SampleCount int `json:"sample_count"` } // SeriesUnit benennt die Einheit einer Zeitreihe. type SeriesUnit string const ( // UnitBytes sind Bytes. UnitBytes SeriesUnit = "bytes" // UnitBytesPerSecond sind Bytes je Sekunde. UnitBytesPerSecond SeriesUnit = "bytes_per_second" // UnitSeconds sind Sekunden. UnitSeconds SeriesUnit = "seconds" // UnitPercent sind Prozent. UnitPercent SeriesUnit = "percent" // UnitCount ist eine Anzahl. UnitCount SeriesUnit = "count" // UnitRatio ist ein Verhaeltnis. UnitRatio SeriesUnit = "ratio" ) // Series ist eine benannte Zeitreihe. type Series struct { // Name ist der maschinenlesbare Bezeichner. Name string `json:"name"` // Label ist die Beschriftung. Label string `json:"label"` // Unit ist die Einheit der Werte. Unit SeriesUnit `json:"unit"` // Points sind die Punkte in zeitlicher Reihenfolge. Points []DataPoint `json:"points"` } // PointsWithValue zaehlt die Punkte mit einer Messung. func (series *Series) PointsWithValue() int { pointCount := 0 for _, dataPoint := range series.Points { if dataPoint.HasValue { pointCount++ } } return pointCount } // Chart ist ein vollstaendiges Diagramm mit einer oder mehreren Reihen. type Chart struct { // Metric ist der Bezeichner des Diagramms. Metric string `json:"metric"` // Title ist die Ueberschrift. Title string `json:"title"` // Description erklaert, was das Diagramm zeigt. Description string `json:"description"` // Window ist der ausgewertete Zeitraum. Window Window `json:"window"` // Series sind die enthaltenen Reihen. Series []Series `json:"series"` // Note ist ein Hinweis zur Aussagekraft. // // Sie erscheint, wenn die Zahlen zwar stimmen, aber wenig hergeben — etwa // bei einem einzigen Datenpunkt. Ein Diagramm mit zwei Punkten sieht aus wie // ein Trend und ist keiner. Note string `json:"note,omitempty"` } // HasAnyData meldet, ob ueberhaupt etwas gemessen wurde. func (chart *Chart) HasAnyData() bool { for seriesIndex := range chart.Series { if chart.Series[seriesIndex].PointsWithValue() > 0 { return true } } return false } // minimumPointsForTrend ist die Zahl der Punkte, ab der eine Kurve etwas aussagt. const minimumPointsForTrend = 3 // AddAssessmentNote versieht ein Diagramm mit einem Hinweis zur Aussagekraft. // // Der Hinweis ist kein Fehler und keine Entschuldigung, sondern eine // Einordnung — dieselbe Ueberlegung wie bei den ungemessenen Groessen der // Recovery Assurance: Was wenig hergibt, soll nicht so aussehen, als gaebe es // viel her. func (chart *Chart) AddAssessmentNote() { if !chart.HasAnyData() { chart.Note = "In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet " + "'keine Daten', nicht 'Wert null'." return } maximumPoints := 0 for seriesIndex := range chart.Series { if pointCount := chart.Series[seriesIndex].PointsWithValue(); pointCount > maximumPoints { maximumPoints = pointCount } } if maximumPoints < minimumPointsForTrend { chart.Note = fmt.Sprintf("Die Reihe enthaelt nur %d Messungen. Das ist zu wenig fuer eine "+ "Aussage ueber eine Entwicklung.", maximumPoints) } } // BuildEmptyPoints legt die Punkte eines Fensters ohne Werte an. // // Jedes Zeitfenster erscheint, auch das leere. Wuerden leere Fenster fehlen, // entstuende eine Kurve mit unregelmaessigem Zeitabstand — und eine // zweistuendige Luecke saehe aus wie ein Sprung. func BuildEmptyPoints(window Window) []DataPoint { bucketCount := window.BucketCount() if bucketCount <= 0 { return []DataPoint{} } if bucketCount > maximumBucketCount { bucketCount = maximumBucketCount } points := make([]DataPoint, 0, bucketCount) for bucketIndex := 0; bucketIndex < bucketCount; bucketIndex++ { points = append(points, DataPoint{ Timestamp: window.From.Add(time.Duration(bucketIndex) * window.BucketWidth), }) } return points } // BucketIndexOf liefert das Zeitfenster eines Zeitpunkts. // // Der zweite Rueckgabewert meldet, ob der Zeitpunkt ueberhaupt im Fenster liegt. // Ohne diese Pruefung landete ein Ereignis knapp ausserhalb des Zeitraums im // ersten oder letzten Fenster und verfaelschte es. func BucketIndexOf(window Window, pointInTime time.Time, bucketCount int) (int, bool) { if pointInTime.Before(window.From) || !pointInTime.Before(window.To) { return 0, false } bucketIndex := int(pointInTime.Sub(window.From) / window.BucketWidth) if bucketIndex < 0 || bucketIndex >= bucketCount { return 0, false } return bucketIndex, true }