mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-21 23:43:27 +01:00
feat(storage): Support 30d badges (#836)
* feat(storage): Add support for 30d uptime badge Fix #714 * Fix typo * Fix test * Fix typo * Improve implementation * Add check in existing test * Add extra test to ensure functionality works * Add support for 30d response time chart too
This commit is contained in:
parent
90ffea9fb6
commit
dd435a8eaf
@ -2127,7 +2127,7 @@ The path to generate a badge is the following:
|
|||||||
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
|
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
|
||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `7d`, `24h` or `1h`
|
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||||
|
|
||||||
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
||||||
@ -2192,7 +2192,7 @@ The endpoint to generate a badge is the following:
|
|||||||
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
|
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
|
||||||
```
|
```
|
||||||
Where:
|
Where:
|
||||||
- `{duration}` is `7d`, `24h` or `1h`
|
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||||
|
|
||||||
|
|
||||||
|
16
api/badge.go
16
api/badge.go
@ -37,11 +37,13 @@ var (
|
|||||||
|
|
||||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
//
|
//
|
||||||
// Valid values for :duration -> 7d, 24h, 1h
|
// Valid values for :duration -> 30d, 7d, 24h, 1h
|
||||||
func UptimeBadge(c *fiber.Ctx) error {
|
func UptimeBadge(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
@ -49,7 +51,7 @@ func UptimeBadge(c *fiber.Ctx) error {
|
|||||||
case "1h":
|
case "1h":
|
||||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key := c.Params("key")
|
||||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
@ -69,12 +71,14 @@ func UptimeBadge(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
//
|
//
|
||||||
// Valid values for :duration -> 7d, 24h, 1h
|
// Valid values for :duration -> 30d, 7d, 24h, 1h
|
||||||
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
@ -82,7 +86,7 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
|||||||
case "1h":
|
case "1h":
|
||||||
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key := c.Params("key")
|
||||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
@ -161,6 +165,8 @@ func HealthBadgeShields(c *fiber.Ctx) error {
|
|||||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
labelWidth = 70
|
||||||
case "7d":
|
case "7d":
|
||||||
labelWidth = 65
|
labelWidth = 65
|
||||||
case "24h":
|
case "24h":
|
||||||
@ -227,6 +233,8 @@ func getBadgeColorFromUptime(uptime float64) string {
|
|||||||
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
||||||
var labelWidth, valueWidth int
|
var labelWidth, valueWidth int
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
labelWidth = 110
|
||||||
case "7d":
|
case "7d":
|
||||||
labelWidth = 105
|
labelWidth = 105
|
||||||
case "24h":
|
case "24h":
|
||||||
|
10
api/chart.go
10
api/chart.go
@ -32,14 +32,18 @@ var (
|
|||||||
|
|
||||||
func ResponseTimeChart(c *fiber.Ctx) error {
|
func ResponseTimeChart(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
|
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||||
|
chartTimestampFormatter = chart.TimeDateValueFormatter
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
|
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||||
}
|
}
|
||||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -88,7 +92,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
|||||||
Width: 1280,
|
Width: 1280,
|
||||||
Height: 300,
|
Height: 300,
|
||||||
XAxis: chart.XAxis{
|
XAxis: chart.XAxis{
|
||||||
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
|
ValueFormatter: chartTimestampFormatter,
|
||||||
GridMajorStyle: gridStyle,
|
GridMajorStyle: gridStyle,
|
||||||
GridMinorStyle: gridStyle,
|
GridMinorStyle: gridStyle,
|
||||||
Style: axisStyle,
|
Style: axisStyle,
|
||||||
|
@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||||
ExpectedCode: http.StatusOK,
|
ExpectedCode: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "chart-response-time-30d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "chart-response-time-with-invalid-duration",
|
Name: "chart-response-time-with-invalid-duration",
|
||||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||||
|
@ -7,8 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
numberOfHoursInTenDays = 10 * 24
|
uptimeCleanUpThreshold = 32 * 24
|
||||||
sevenDays = 7 * 24 * time.Hour
|
uptimeRetention = 30 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime
|
// processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime
|
||||||
@ -30,10 +30,10 @@ func processUptimeAfterResult(uptime *endpoint.Uptime, result *endpoint.Result)
|
|||||||
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
|
hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds())
|
||||||
// Clean up only when we're starting to have too many useless keys
|
// Clean up only when we're starting to have too many useless keys
|
||||||
// Note that this is only triggered when there are more entries than there should be after
|
// Note that this is only triggered when there are more entries than there should be after
|
||||||
// 10 days, despite the fact that we are deleting everything that's older than 7 days.
|
// 32 days, despite the fact that we are deleting everything that's older than 30 days.
|
||||||
// This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 7 days.
|
// This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 30 days.
|
||||||
if len(uptime.HourlyStatistics) > numberOfHoursInTenDays {
|
if len(uptime.HourlyStatistics) > uptimeCleanUpThreshold {
|
||||||
sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour)).Unix()
|
sevenDaysAgo := time.Now().Add(-(uptimeRetention + time.Hour)).Unix()
|
||||||
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
|
for hourlyUnixTimestamp := range uptime.HourlyStatistics {
|
||||||
if sevenDaysAgo > hourlyUnixTimestamp {
|
if sevenDaysAgo > hourlyUnixTimestamp {
|
||||||
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
|
delete(uptime.HourlyStatistics, hourlyUnixTimestamp)
|
||||||
|
@ -51,8 +51,8 @@ func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) {
|
|||||||
timestamp := now.Add(-12 * 24 * time.Hour)
|
timestamp := now.Add(-12 * 24 * time.Hour)
|
||||||
for timestamp.Unix() <= now.Unix() {
|
for timestamp.Unix() <= now.Unix() {
|
||||||
AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true})
|
AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true})
|
||||||
if len(status.Uptime.HourlyStatistics) > numberOfHoursInTenDays {
|
if len(status.Uptime.HourlyStatistics) > uptimeCleanUpThreshold {
|
||||||
t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(status.Uptime.HourlyStatistics))
|
t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", uptimeCleanUpThreshold, len(status.Uptime.HourlyStatistics))
|
||||||
}
|
}
|
||||||
// Simulate endpoint with an interval of 3 minutes
|
// Simulate endpoint with an interval of 3 minutes
|
||||||
timestamp = timestamp.Add(3 * time.Minute)
|
timestamp = timestamp.Add(3 * time.Minute)
|
||||||
|
@ -28,11 +28,13 @@ const (
|
|||||||
// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.
|
// for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table.
|
||||||
arraySeparator = "|~|"
|
arraySeparator = "|~|"
|
||||||
|
|
||||||
uptimeCleanUpThreshold = 10 * 24 * time.Hour // Maximum uptime age before triggering a cleanup
|
|
||||||
eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a cleanup
|
eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a cleanup
|
||||||
resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a cleanup
|
resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a cleanup
|
||||||
|
|
||||||
uptimeRetention = 7 * 24 * time.Hour
|
uptimeTotalEntriesMergeThreshold = 100 // Maximum number of uptime entries before triggering a merge
|
||||||
|
uptimeAgeCleanUpThreshold = 32 * 24 * time.Hour // Maximum uptime age before triggering a cleanup
|
||||||
|
uptimeRetention = 30 * 24 * time.Hour // Minimum duration that must be kept to operate as intended
|
||||||
|
uptimeHourlyBuffer = 48 * time.Hour // Number of hours to buffer from now when determining which hourly uptime entries can be merged into daily uptime entries
|
||||||
|
|
||||||
cacheTTL = 10 * time.Minute
|
cacheTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
@ -322,12 +324,27 @@ func (s *Store) Insert(ep *endpoint.Endpoint, result *endpoint.Result) error {
|
|||||||
if err = s.updateEndpointUptime(tx, endpointID, result); err != nil {
|
if err = s.updateEndpointUptime(tx, endpointID, result); err != nil {
|
||||||
log.Printf("[sql.Insert] Failed to update uptime for endpoint with key=%s: %s", ep.Key(), err.Error())
|
log.Printf("[sql.Insert] Failed to update uptime for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
}
|
}
|
||||||
// Clean up old uptime entries
|
// Merge hourly uptime entries that can be merged into daily entries and clean up old uptime entries
|
||||||
|
numberOfUptimeEntries, err := s.getNumberOfUptimeEntriesByEndpointID(tx, endpointID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[sql.Insert] Failed to retrieve total number of uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
|
} else {
|
||||||
|
// Merge older hourly uptime entries into daily uptime entries if we have more than uptimeTotalEntriesMergeThreshold
|
||||||
|
if numberOfUptimeEntries >= uptimeTotalEntriesMergeThreshold {
|
||||||
|
log.Printf("[sql.Insert] Merging hourly uptime entries for endpoint with key=%s; This is a lot of work, it shouldn't happen too often", ep.Key())
|
||||||
|
if err = s.mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx, endpointID); err != nil {
|
||||||
|
log.Printf("[sql.Insert] Failed to merge hourly uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up outdated uptime entries
|
||||||
|
// In most cases, this would be handled by mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries,
|
||||||
|
// but if Gatus was temporarily shut down, we might have some old entries that need to be cleaned up
|
||||||
ageOfOldestUptimeEntry, err := s.getAgeOfOldestEndpointUptimeEntry(tx, endpointID)
|
ageOfOldestUptimeEntry, err := s.getAgeOfOldestEndpointUptimeEntry(tx, endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[sql.Insert] Failed to retrieve oldest endpoint uptime entry for endpoint with key=%s: %s", ep.Key(), err.Error())
|
log.Printf("[sql.Insert] Failed to retrieve oldest endpoint uptime entry for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
} else {
|
} else {
|
||||||
if ageOfOldestUptimeEntry > uptimeCleanUpThreshold {
|
if ageOfOldestUptimeEntry > uptimeAgeCleanUpThreshold {
|
||||||
if err = s.deleteOldUptimeEntries(tx, endpointID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil {
|
if err = s.deleteOldUptimeEntries(tx, endpointID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil {
|
||||||
log.Printf("[sql.Insert] Failed to delete old uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
|
log.Printf("[sql.Insert] Failed to delete old uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error())
|
||||||
}
|
}
|
||||||
@ -865,6 +882,12 @@ func (s *Store) getNumberOfResultsByEndpointID(tx *sql.Tx, endpointID int64) (in
|
|||||||
return numberOfResults, err
|
return numberOfResults, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) getNumberOfUptimeEntriesByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) {
|
||||||
|
var numberOfUptimeEntries int64
|
||||||
|
err := tx.QueryRow("SELECT COUNT(1) FROM endpoint_uptimes WHERE endpoint_id = $1", endpointID).Scan(&numberOfUptimeEntries)
|
||||||
|
return numberOfUptimeEntries, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) getAgeOfOldestEndpointUptimeEntry(tx *sql.Tx, endpointID int64) (time.Duration, error) {
|
func (s *Store) getAgeOfOldestEndpointUptimeEntry(tx *sql.Tx, endpointID int64) (time.Duration, error) {
|
||||||
rows, err := tx.Query(
|
rows, err := tx.Query(
|
||||||
`
|
`
|
||||||
@ -948,6 +971,92 @@ func (s *Store) deleteOldUptimeEntries(tx *sql.Tx, endpointID int64, maxAge time
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries merges all hourly uptime entries older than
|
||||||
|
// uptimeHourlyMergeThreshold from now into daily uptime entries by summing all hourly entries of the same day into a
|
||||||
|
// single entry.
|
||||||
|
//
|
||||||
|
// This effectively limits the number of uptime entries to (48+(n-2)) where 48 is for the first 48 entries with hourly
|
||||||
|
// entries (defined by uptimeHourlyBuffer) and n is the number of days for all entries older than 48 hours.
|
||||||
|
// Supporting 30d of entries would then result in far less than 24*30=720 entries.
|
||||||
|
func (s *Store) mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx *sql.Tx, endpointID int64) error {
|
||||||
|
// Calculate timestamp of the first full day of uptime entries that would not impact the uptime calculation for 24h badges
|
||||||
|
// The logic is that once at least 48 hours passed, we:
|
||||||
|
// - No longer need to worry about keeping hourly entries
|
||||||
|
// - Don't have to worry about new hourly entries being inserted, as the day has already passed
|
||||||
|
// which implies that no matter at what hour of the day we are, any timestamp + 48h floored to the current day
|
||||||
|
// will never impact the 24h uptime badge calculation
|
||||||
|
now := time.Now()
|
||||||
|
minThreshold := now.Add(-uptimeHourlyBuffer)
|
||||||
|
minThreshold = time.Date(minThreshold.Year(), minThreshold.Month(), minThreshold.Day(), 0, 0, 0, 0, minThreshold.Location())
|
||||||
|
maxThreshold := now.Add(-uptimeRetention)
|
||||||
|
// Get all uptime entries older than uptimeHourlyMergeThreshold
|
||||||
|
rows, err := tx.Query(
|
||||||
|
`
|
||||||
|
SELECT hour_unix_timestamp, total_executions, successful_executions, total_response_time
|
||||||
|
FROM endpoint_uptimes
|
||||||
|
WHERE endpoint_id = $1
|
||||||
|
AND hour_unix_timestamp < $2
|
||||||
|
AND hour_unix_timestamp >= $3
|
||||||
|
`,
|
||||||
|
endpointID,
|
||||||
|
minThreshold.Unix(),
|
||||||
|
maxThreshold.Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type Entry struct {
|
||||||
|
totalExecutions int
|
||||||
|
successfulExecutions int
|
||||||
|
totalResponseTime int
|
||||||
|
}
|
||||||
|
dailyEntries := make(map[int64]*Entry)
|
||||||
|
for rows.Next() {
|
||||||
|
var unixTimestamp int64
|
||||||
|
entry := Entry{}
|
||||||
|
if err = rows.Scan(&unixTimestamp, &entry.totalExecutions, &entry.successfulExecutions, &entry.totalResponseTime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
timestamp := time.Unix(unixTimestamp, 0)
|
||||||
|
unixTimestampFlooredAtDay := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, timestamp.Location()).Unix()
|
||||||
|
if dailyEntry := dailyEntries[unixTimestampFlooredAtDay]; dailyEntry == nil {
|
||||||
|
dailyEntries[unixTimestampFlooredAtDay] = &entry
|
||||||
|
} else {
|
||||||
|
dailyEntries[unixTimestampFlooredAtDay].totalExecutions += entry.totalExecutions
|
||||||
|
dailyEntries[unixTimestampFlooredAtDay].successfulExecutions += entry.successfulExecutions
|
||||||
|
dailyEntries[unixTimestampFlooredAtDay].totalResponseTime += entry.totalResponseTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete older hourly uptime entries
|
||||||
|
_, err = tx.Exec("DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2", endpointID, minThreshold.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Insert new daily uptime entries
|
||||||
|
for unixTimestamp, entry := range dailyEntries {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`
|
||||||
|
INSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET
|
||||||
|
total_executions = $3,
|
||||||
|
successful_executions = $4,
|
||||||
|
total_response_time = $5
|
||||||
|
`,
|
||||||
|
endpointID,
|
||||||
|
unixTimestamp,
|
||||||
|
entry.totalExecutions,
|
||||||
|
entry.successfulExecutions,
|
||||||
|
entry.totalResponseTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Find a way to ignore entries that were already merged?
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func generateCacheKey(endpointKey string, p *paging.EndpointStatusParams) string {
|
func generateCacheKey(endpointKey string, p *paging.EndpointStatusParams) string {
|
||||||
return fmt.Sprintf("%s-%d-%d-%d-%d", endpointKey, p.EventsPage, p.EventsPageSize, p.ResultsPage, p.ResultsPageSize)
|
return fmt.Sprintf("%s-%d-%d-%d-%d", endpointKey, p.EventsPage, p.EventsPageSize, p.ResultsPage, p.ResultsPageSize)
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package sql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -132,18 +133,18 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one
|
// Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one
|
||||||
store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold - time.Hour)), Success: true})
|
store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold - time.Hour)), Success: true})
|
||||||
|
|
||||||
tx, _ = store.db.Begin()
|
tx, _ = store.db.Begin()
|
||||||
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
|
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
|
||||||
_ = tx.Commit()
|
_ = tx.Commit()
|
||||||
if oldest.Truncate(time.Hour) != uptimeCleanUpThreshold-time.Hour {
|
if oldest.Truncate(time.Hour) != uptimeAgeCleanUpThreshold-time.Hour {
|
||||||
t.Errorf("oldest endpoint uptime entry should've been ~%s hours old, was %s", uptimeCleanUpThreshold-time.Hour, oldest)
|
t.Errorf("oldest endpoint uptime entry should've been ~%s hours old, was %s", uptimeAgeCleanUpThreshold-time.Hour, oldest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since this entry is after the uptimeCleanUpThreshold, both this entry as well as the previous
|
// Since this entry is after the uptimeAgeCleanUpThreshold, both this entry as well as the previous
|
||||||
// one should be deleted since they both surpass uptimeRetention
|
// one should be deleted since they both surpass uptimeRetention
|
||||||
store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold + time.Hour)), Success: true})
|
store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold + time.Hour)), Success: true})
|
||||||
|
|
||||||
tx, _ = store.db.Begin()
|
tx, _ = store.db.Begin()
|
||||||
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
|
oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1)
|
||||||
@ -153,8 +154,128 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly(t *testing.T) {
|
||||||
|
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly.db", false)
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Now().Truncate(time.Hour)
|
||||||
|
now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
numberOfHours int
|
||||||
|
expectedMaxUptimeEntries int64
|
||||||
|
}{
|
||||||
|
{numberOfHours: 1, expectedMaxUptimeEntries: 1},
|
||||||
|
{numberOfHours: 10, expectedMaxUptimeEntries: 10},
|
||||||
|
{numberOfHours: 50, expectedMaxUptimeEntries: 50},
|
||||||
|
{numberOfHours: 75, expectedMaxUptimeEntries: 75},
|
||||||
|
{numberOfHours: 99, expectedMaxUptimeEntries: 99},
|
||||||
|
{numberOfHours: 150, expectedMaxUptimeEntries: 100},
|
||||||
|
{numberOfHours: 300, expectedMaxUptimeEntries: 100},
|
||||||
|
{numberOfHours: 768, expectedMaxUptimeEntries: 100}, // 32 days (in hours), which means anything beyond that won't be persisted anyway
|
||||||
|
{numberOfHours: 1000, expectedMaxUptimeEntries: 100},
|
||||||
|
}
|
||||||
|
// Note that is not technically an accurate real world representation, because uptime entries are always added in
|
||||||
|
// the present, while this test is inserting results from the past to simulate long term uptime entries.
|
||||||
|
// Since we want to test the behavior and not the test itself, this is a "best effort" approach.
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("num-hours-%d-expected-max-entries-%d", scenario.numberOfHours, scenario.expectedMaxUptimeEntries), func(t *testing.T) {
|
||||||
|
for i := scenario.numberOfHours; i > 0; i-- {
|
||||||
|
//fmt.Printf("i: %d (%s)\n", i, now.Add(-time.Duration(i)*time.Hour))
|
||||||
|
// Create an uptime entry
|
||||||
|
err := store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-time.Duration(i) * time.Hour), Success: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
//// DEBUGGING: check number of uptime entries for endpoint
|
||||||
|
//tx, _ := store.db.Begin()
|
||||||
|
//numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
|
||||||
|
//if err != nil {
|
||||||
|
// t.Log(err)
|
||||||
|
//}
|
||||||
|
//_ = tx.Commit()
|
||||||
|
//t.Logf("i=%d; numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", i, scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)
|
||||||
|
}
|
||||||
|
// check number of uptime entries for endpoint
|
||||||
|
tx, _ := store.db.Begin()
|
||||||
|
numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
_ = tx.Commit()
|
||||||
|
//t.Logf("numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1)
|
||||||
|
if scenario.expectedMaxUptimeEntries < numberOfUptimeEntriesForEndpoint {
|
||||||
|
t.Errorf("expected %d (uptime entries) to be smaller than %d", numberOfUptimeEntriesForEndpoint, scenario.expectedMaxUptimeEntries)
|
||||||
|
}
|
||||||
|
store.Clear()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_getEndpointUptime(t *testing.T) {
|
||||||
|
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false)
|
||||||
|
defer store.Clear()
|
||||||
|
defer store.Close()
|
||||||
|
// Add 768 hourly entries (32 days)
|
||||||
|
// Daily entries should be merged from hourly entries automatically
|
||||||
|
for i := 768; i > 0; i-- {
|
||||||
|
err := store.Insert(&testEndpoint, &endpoint.Result{Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), Duration: time.Second, Success: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check the number of uptime entries
|
||||||
|
tx, _ := store.db.Begin()
|
||||||
|
numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
if numberOfUptimeEntriesForEndpoint < 20 || numberOfUptimeEntriesForEndpoint > 200 {
|
||||||
|
t.Errorf("expected number of uptime entries to be between 20 and 200, got %d", numberOfUptimeEntriesForEndpoint)
|
||||||
|
}
|
||||||
|
// Retrieve uptime for the past 30d
|
||||||
|
uptime, avgResponseTime, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
_ = tx.Commit()
|
||||||
|
if avgResponseTime != time.Second {
|
||||||
|
t.Errorf("expected average response time to be %s, got %s", time.Second, avgResponseTime)
|
||||||
|
}
|
||||||
|
if uptime != 1 {
|
||||||
|
t.Errorf("expected uptime to be 1, got %f", uptime)
|
||||||
|
}
|
||||||
|
// Add a new unsuccessful result, which should impact the uptime
|
||||||
|
err = store.Insert(&testEndpoint, &endpoint.Result{Timestamp: time.Now(), Duration: time.Second, Success: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
// Retrieve uptime for the past 30d
|
||||||
|
tx, _ = store.db.Begin()
|
||||||
|
uptime, _, err = store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
_ = tx.Commit()
|
||||||
|
if uptime == 1 {
|
||||||
|
t.Errorf("expected uptime to be less than 1, got %f", uptime)
|
||||||
|
}
|
||||||
|
// Retrieve uptime for the past 30d, but excluding the last 24h
|
||||||
|
// This is not a real use case as there is no way for users to exclude the last 24h, but this is a great way
|
||||||
|
// to ensure that hourly merging works as intended
|
||||||
|
tx, _ = store.db.Begin()
|
||||||
|
uptimeExcludingLast24h, _, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now().Add(-24*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
_ = tx.Commit()
|
||||||
|
if uptimeExcludingLast24h == uptime {
|
||||||
|
t.Error("expected uptimeExcludingLast24h to to be different from uptime, got")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
|
func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
|
||||||
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false)
|
store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false)
|
||||||
|
defer store.Clear()
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {
|
for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ {
|
||||||
store.Insert(&testEndpoint, &testSuccessfulResult)
|
store.Insert(&testEndpoint, &testSuccessfulResult)
|
||||||
@ -167,7 +288,6 @@ func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) {
|
|||||||
t.Errorf("number of events shouldn't have exceeded %d, reached %d", eventsCleanUpThreshold, len(ss.Events))
|
t.Errorf("number of events shouldn't have exceeded %d, reached %d", eventsCleanUpThreshold, len(ss.Events))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
store.Clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStore_InsertWithCaching(t *testing.T) {
|
func TestStore_InsertWithCaching(t *testing.T) {
|
||||||
@ -218,6 +338,9 @@ func TestStore_Persistence(t *testing.T) {
|
|||||||
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
|
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 {
|
||||||
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
|
t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime)
|
||||||
}
|
}
|
||||||
|
if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*30), time.Now()); uptime != 0.5 {
|
||||||
|
t.Errorf("the uptime over the past 30d should've been 0.5, got %f", uptime)
|
||||||
|
}
|
||||||
ssFromOldStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
|
ssFromOldStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents))
|
||||||
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {
|
if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 {
|
||||||
store.Close()
|
store.Close()
|
||||||
|
Loading…
Reference in New Issue
Block a user