From c44d998fb3e9c237e0a9d99f2a41bb4742141336 Mon Sep 17 00:00:00 2001 From: JamesHillyard <73830120+JamesHillyard@users.noreply.github.com> Date: Sat, 28 Dec 2024 20:59:28 +0000 Subject: [PATCH] feat(api): Expose uptime data as text via API (#758) * Expose Raw Uptime Data via API Signed-off-by: James Hillyard * Add Test for Raw Uptime Data API Endpoint Signed-off-by: James Hillyard * Document Raw Uptime Data API Endpoint Signed-off-by: James Hillyard * Fix Test after #759 Core Refactor Signed-off-by: James Hillyard * Update Raw Data Content Type Signed-off-by: James Hillyard * Support 30d Data from Raw Uptime Endpoint Signed-off-by: James Hillyard * Update README.md * Update README.md --------- Signed-off-by: James Hillyard Co-authored-by: TwiN --- README.md | 18 ++++++++++ api/api.go | 1 + api/raw.go | 43 +++++++++++++++++++++++ api/raw_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 api/raw.go create mode 100644 api/raw_test.go diff --git a/README.md b/README.md index 91edf12a7..8aed0982b 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Response time](#response-time) - [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge) - [API](#api) + - [Raw Data](#raw-data) - [Installing as binary](#installing-as-binary) - [High level design overview](#high-level-design-overview) @@ -2404,6 +2405,23 @@ Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzi The API will return a JSON payload with the `Content-Type` response header set to `application/json`. No such header is required to query the API. +#### Raw Data +Gatus exposes the raw data for one of your monitored endpoints. +This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days. + +##### Uptime +The path to get raw uptime data for an endpoint is: +``` +/api/v1/endpoints/{key}/uptimes/{duration} +``` +Where: +- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h` +- `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. + +For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this: +``` +https://example.com/api/v1/endpoints/core_frontend/uptimes/24h +``` ### Installing as binary You can download Gatus as a binary using the following command: diff --git a/api/api.go b/api/api.go index 1ac604a15..9ecc56f49 100644 --- a/api/api.go +++ b/api/api.go @@ -78,6 +78,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App { unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields) + unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw) unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg)) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart) diff --git a/api/raw.go b/api/raw.go new file mode 100644 index 000000000..34fe67262 --- /dev/null +++ b/api/raw.go @@ -0,0 +1,43 @@ +package api + +import ( + "errors" + "fmt" + "time" + + "github.com/TwiN/gatus/v5/storage/store" + "github.com/TwiN/gatus/v5/storage/store/common" + "github.com/gofiber/fiber/v2" +) + +func UptimeRaw(c *fiber.Ctx) error { + duration := c.Params("duration") + var from time.Time + switch duration { + case "30d": + from = time.Now().Add(-30 * 24 * time.Hour) + case "7d": + from = time.Now().Add(-7 * 24 * time.Hour) + case "24h": + from = time.Now().Add(-24 * time.Hour) + case "1h": + from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little + default: + return c.Status(400).SendString("Durations supported: 30d,7d, 24h, 1h") + } + key := c.Params("key") + uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) + if err != nil { + if errors.Is(err, common.ErrEndpointNotFound) { + return c.Status(404).SendString(err.Error()) + } else if errors.Is(err, common.ErrInvalidTimeRange) { + return c.Status(400).SendString(err.Error()) + } + return c.Status(500).SendString(err.Error()) + } + + c.Set("Content-Type", "text/plain") + c.Set("Cache-Control", "no-cache, no-store, must-revalidate") + c.Set("Expires", "0") + return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime))) +} diff --git a/api/raw_test.go b/api/raw_test.go new file mode 100644 index 000000000..d227c0aad --- /dev/null +++ b/api/raw_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/config/endpoint/ui" + "github.com/TwiN/gatus/v5/storage/store" + "github.com/TwiN/gatus/v5/watchdog" +) + +func TestRawDataEndpoint(t *testing.T) { + defer store.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Metrics: true, + Endpoints: []*endpoint.Endpoint{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + + cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() + cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig() + + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) + api := New(cfg) + router := api.Router() + type Scenario struct { + Name string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Name: "raw-uptime-1h", + Path: "/api/v1/endpoints/core_frontend/uptimes/1h", + ExpectedCode: http.StatusOK, + }, + { + Name: "raw-uptime-24h", + Path: "/api/v1/endpoints/core_backend/uptimes/24h", + ExpectedCode: http.StatusOK, + }, + { + Name: "raw-uptime-7d", + Path: "/api/v1/endpoints/core_frontend/uptimes/7d", + ExpectedCode: http.StatusOK, + }, + { + Name: "raw-uptime-30d", + Path: "/api/v1/endpoints/core_frontend/uptimes/30d", + ExpectedCode: http.StatusOK, + }, + { + Name: "raw-uptime-with-invalid-duration", + Path: "/api/v1/endpoints/core_backend/uptimes/3d", + ExpectedCode: http.StatusBadRequest, + }, + { + Name: "raw-uptime-for-invalid-key", + Path: "/api/v1/endpoints/invalid_key/uptimes/7d", + ExpectedCode: http.StatusNotFound, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request := httptest.NewRequest("GET", scenario.Path, http.NoBody) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + response, err := router.Test(request) + if err != nil { + return + } + if response.StatusCode != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) + } + }) + } +}