Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ Here are some examples of conditions you can use:
| `len([BODY].name) == 8` | String at JSONPath `$.name` has a length of 8 | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `has([BODY].errors) == false` | JSONPath `$.errors` does not exist | `{"name":"john.doe"}` | `{"errors":[]}` |
| `has([BODY].users) == true` | JSONPath `$.users` exists | `{"users":[]}` | `{}` |
| `age([BODY].updated) < 15m` | JSONPath `$.updated` is a timestamp or relative time| `{"updated":"10m ago"}` | `{}` |
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
Expand All @@ -509,6 +510,7 @@ Here are some examples of conditions you can use:
|:---------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------|
| `len` | If the given path leads to an array, returns its length. Otherwise, the JSON at the given path is minified and converted to a string, and the resulting number of characters is returned. Works only with the `[BODY]` placeholder. | `len([BODY].username) > 8` |
| `has` | Returns `true` or `false` based on whether a given path is valid. Works only with the `[BODY]` placeholder. | `has([BODY].errors) == false` |
| `age` | If the given path or placeholder resolves to a supported [timestamp format](https://github.com/markusmobius/go-dateparser), the age of the timestamp is returned as elapsed time since now (can be negative when in the future). | `age([BODY].lastUpdated) < 15m` |
| `pat` | Specifies that the string passed as parameter should be evaluated as a pattern. Works only with `==` and `!=`. | `[IP] == pat(192.168.*)` |
| `any` | Specifies that any one of the values passed as parameters is a valid value. Works only with `==` and `!=`. | `[BODY].ip == any(127.0.0.1, ::1)` |

Expand Down
70 changes: 61 additions & 9 deletions config/endpoint/placeholder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package endpoint

import (
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/jsonpath"
dateparser "github.com/markusmobius/go-dateparser"
)

// Placeholders
Expand Down Expand Up @@ -66,6 +69,11 @@ const (
// Usage: has([BODY].errors) == true
HasFunctionPrefix = "has("

// AgeFunctionPrefix is the prefix for the age function
//
// Usage: age([BODY].timestamp) > 15m
AgeFunctionPrefix = "age("

// PatternFunctionPrefix is the prefix for the pattern function
//
// Usage: [IP] == pat(192.168.*.*)
Expand Down Expand Up @@ -96,6 +104,7 @@ const (
noFunction functionType = iota
functionLen
functionHas
functionAge
)

// ResolvePlaceholder resolves all types of placeholders to their string values.
Expand All @@ -115,6 +124,7 @@ const (
// Function wrappers:
// - len(placeholder): Returns the length of the resolved value
// - has(placeholder): Returns "true" if the placeholder exists and is non-empty, "false" otherwise
// - age(placeholder): Returns the age of the resolved value in milliseconds, or an error message if parsing fails
//
// Examples:
// - ResolvePlaceholder("[STATUS]", result, nil) → "200"
Expand Down Expand Up @@ -154,21 +164,15 @@ func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext
case DomainExpirationPlaceholder:
return formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil
case BodyPlaceholder:
body := strings.TrimSpace(string(result.Body))
if fn == functionHas {
return strconv.FormatBool(len(body) > 0), nil
}
if fn == functionLen {
// For len([BODY]), we need to check if it's JSON and get the actual length
// Use jsonpath to evaluate the root element
_, resolvedLength, err := jsonpath.Eval("", result.Body)
if err == nil {
return strconv.Itoa(resolvedLength), nil
}
// Fall back to string length if not valid JSON
return strconv.Itoa(len(body)), nil
}
return body, nil
return formatWithFunction(strings.TrimSpace(string(result.Body)), fn), nil
}

// Handle JSONPath expressions on BODY (including array indexing)
Expand Down Expand Up @@ -200,6 +204,10 @@ func extractFunctionWrapper(placeholder string) (functionType, string) {
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix)
return functionHas, inner
}
if strings.HasPrefix(placeholder, AgeFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, AgeFunctionPrefix), FunctionSuffix)
return functionAge, inner
}
return noFunction, placeholder
}

Expand All @@ -222,9 +230,14 @@ func resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPla
if err != nil {
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
if fn == functionLen {

switch fn {
case functionLen:
return strconv.Itoa(resolvedLength), nil
case functionAge:
return parseAge(resolvedValue), nil
}

return resolvedValue, nil
}

Expand All @@ -245,7 +258,9 @@ func resolveContextPlaceholder(placeholder string, fn functionType, originalPlac
if err != nil {
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
}
if fn == functionLen {

switch fn {
case functionLen:
switch v := value.(type) {
case string:
return strconv.Itoa(len(v)), nil
Expand All @@ -256,7 +271,10 @@ func resolveContextPlaceholder(placeholder string, fn functionType, originalPlac
default:
return strconv.Itoa(len(fmt.Sprintf("%v", v))), nil
}
case functionAge:
return parseAge(fmt.Sprintf("%v", value)), nil
}

return fmt.Sprintf("%v", value), nil
}

Expand All @@ -267,7 +285,41 @@ func formatWithFunction(value string, fn functionType) string {
return strconv.FormatBool(value != "")
case functionLen:
return strconv.Itoa(len(value))
case functionAge:
return parseAge(value)
default:
return value
}
}

var nginxDateFmt = regexp.MustCompile(`^\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}.*`)

// parseAge parses the timestamp (using a broad list of formats) and returns the age in milliseconds as a string, or an error message if parsing fails.
func parseAge(timestamp string) string {
cfg := &dateparser.Configuration{
DefaultTimezone: time.Local,
DefaultLanguages: []string{"en"},
PreferredDayOfMonth: dateparser.Current,
PreferredMonthOfYear: dateparser.CurrentMonth,
PreserveEndOfMonth: true,
}

timestamp = strings.TrimSpace(timestamp)

// Adjustments for specific formats that would fail parsing
if ts, err := strconv.ParseFloat(timestamp, 64); err == nil {
if ts < 1e12 {
ts = ts * 1000.0 // If it's in seconds, convert to milliseconds
}
timestamp = strconv.FormatInt(int64(ts), 10) // Convert "1.771584249972e+12" to "1771584249972"
} else if nginxDateFmt.MatchString(timestamp) {
timestamp = timestamp[:11] + " " + timestamp[12:] // Convert "12/Oct/2024:15:13:06 +0200" to "12/Oct/2024 15:13:06 +0200"
}

if parsed, err := dateparser.Parse(cfg, timestamp); err == nil {
// fmt.Printf("%v\n", parsed)
return strconv.FormatInt(time.Since(parsed.Time).Milliseconds(), 10)
} else {
return fmt.Sprintf("%v", err)
}
}
103 changes: 100 additions & 3 deletions config/endpoint/placeholder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package endpoint

import (
"fmt"
"strconv"
"strings"
"testing"
"time"

Expand All @@ -16,7 +19,7 @@ func TestResolvePlaceholder(t *testing.T) {
Connected: true,
CertificateExpiration: 30 * 24 * time.Hour,
DomainExpiration: 365 * 24 * time.Hour,
Body: []byte(`{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`),
Body: []byte(`{"status":"success", "ts":"2 minutes","items":[1,2,3],"user":{"name":"john","id":123}}`),
}

ctx := gontext.New(map[string]interface{}{
Expand All @@ -26,6 +29,7 @@ func TestResolvePlaceholder(t *testing.T) {
"nested": map[string]interface{}{
"value": "test",
},
"timestamp": "4 minutes",
})

tests := []struct {
Expand All @@ -41,7 +45,7 @@ func TestResolvePlaceholder(t *testing.T) {
{"connected", "[CONNECTED]", "true"},
{"certificate-expiration", "[CERTIFICATE_EXPIRATION]", "2592000000"},
{"domain-expiration", "[DOMAIN_EXPIRATION]", "31536000000"},
{"body", "[BODY]", `{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`},
{"body", "[BODY]", `{"status":"success", "ts":"2 minutes","items":[1,2,3],"user":{"name":"john","id":123}}`},

// Case insensitive placeholders
{"status-lowercase", "[status]", "200"},
Expand All @@ -52,6 +56,9 @@ func TestResolvePlaceholder(t *testing.T) {
{"len-ip", "len([IP])", "9"},
{"has-status", "has([STATUS])", "true"},
{"has-empty", "has()", "false"},
{"len-empty", "len()", "len() (INVALID)"},
{"age-empty", "age()", "age() (INVALID)"},
{"age-dns-rcode", "age([DNS_RCODE])", `failed to parse "NOERROR": unknown format`},

// JSONPath expressions
{"body-status", "[BODY].status", "success"},
Expand All @@ -61,6 +68,8 @@ func TestResolvePlaceholder(t *testing.T) {
{"body-array-index", "[BODY].items[0]", "1"},
{"has-body-status", "has([BODY].status)", "true"},
{"has-body-missing", "has([BODY].missing)", "false"},
{"age-body-ts", "age([BODY].ts)", "age: 120000"},
{"age-body-status", "age([BODY].status)", `failed to parse "success": unknown format`},

// Context placeholders
{"context-user-id", "[CONTEXT].user_id", "abc123"},
Expand All @@ -69,6 +78,7 @@ func TestResolvePlaceholder(t *testing.T) {
{"len-context-array", "len([CONTEXT].array_data)", "3"},
{"has-context-user-id", "has([CONTEXT].user_id)", "true"},
{"has-context-missing", "has([CONTEXT].missing)", "false"},
{"age-context-timestamp", "age([CONTEXT].timestamp)", "age: 240000"},

// Invalid placeholders
{"unknown-placeholder", "[UNKNOWN]", "[UNKNOWN]"},
Expand All @@ -87,7 +97,9 @@ func TestResolvePlaceholder(t *testing.T) {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if actual != test.expected {
if strings.HasPrefix(test.expected, "age: ") {
assertAgeInRange(t, actual, test.expected)
} else if actual != test.expected {
t.Errorf("expected '%s', got '%s'", test.expected, actual)
}
})
Expand Down Expand Up @@ -123,3 +135,88 @@ func TestResolvePlaceholderWithoutContext(t *testing.T) {
})
}
}

func assertAgeInRange(t *testing.T, actual string, expected string) {
expectedAge, _ := strconv.Atoi(strings.TrimPrefix(expected, "age: "))
actualAge, err := strconv.Atoi(actual)
if err != nil {
t.Errorf("expected an age in milliseconds, got '%s'", actual)
} else if actualAge < expectedAge-1000 || actualAge > expectedAge+1000 {
t.Errorf("expected age around %d ms, got %d ms", expectedAge, actualAge)
}
}

func TestParseAge(t *testing.T) {
makeAge := func(duration time.Duration) string {
return fmt.Sprintf("age: %d", duration.Milliseconds())
}

now := time.Now()

absoluteDate := time.Date(2024, 10, 12, 15, 13, 6, 0, time.UTC)
absoluteDateLocal := time.Date(2024, 10, 12, 15, 13, 6, 0, time.Local)
absoluteDateOffset := time.Date(2024, 10, 12, 15, 13, 6, 0, time.FixedZone("Offset", 4*60*60))

timeOnly := time.Date(now.Year(), now.Month(), now.Day(), 4, 13, 6, 0, time.UTC)
timeOnlyLocal := time.Date(now.Year(), now.Month(), now.Day(), 4, 13, 6, 0, time.Local)

tests := []struct {
timestamp string
expected string
}{
// absolute
{"2024-10-12T15:13:06Z", makeAge(time.Since(absoluteDate))},
{" 2024-10-12T15:13:06Z", makeAge(time.Since(absoluteDate))},
{"2024-10-12T15:13:06Z ", makeAge(time.Since(absoluteDate))},
{" 2024-10-12T15:13:06Z ", makeAge(time.Since(absoluteDate))},
{"2024-10-12T15:13:06", makeAge(time.Since(absoluteDateLocal))},
{"2024-10-12 15:13:06", makeAge(time.Since(absoluteDateLocal))},
{"2024-10-12 15:13:06+04:00", makeAge(time.Since(absoluteDateOffset))},
{"Sat Oct 12 15:13:06 UTC 2024", makeAge(time.Since(absoluteDate))},
{"Sat, 12 Oct 2024 15:13:06.371213 UTC", makeAge(time.Since(absoluteDate))},
{"12/Oct/2024 15:13:06 +0400", makeAge(time.Since(absoluteDateOffset))},
{"12/Oct/2024:15:13:06 +0000", makeAge(time.Since(absoluteDate))},
{"2024/10/12 15:13:06", makeAge(time.Since(absoluteDateLocal))},
// unix timestamp variants
{fmt.Sprintf("%d", absoluteDate.Unix()), makeAge(time.Since(absoluteDate))},
{fmt.Sprintf("%d", absoluteDate.UnixMilli()), makeAge(time.Since(absoluteDate))},
{fmt.Sprintf("%f", float64(absoluteDate.UnixMilli())/1000.0), makeAge(time.Since(absoluteDate))},
// unix timestamp variants with local TZ
{fmt.Sprintf("%d", absoluteDateLocal.Unix()), makeAge(time.Since(absoluteDateLocal))},
{fmt.Sprintf("%d", absoluteDateLocal.UnixMilli()), makeAge(time.Since(absoluteDateLocal))},
{fmt.Sprintf("%f", float64(absoluteDateLocal.UnixMilli())/1000.0), makeAge(time.Since(absoluteDateLocal))},
// time only
{"04:13:06Z", makeAge(time.Since(timeOnly))},
{"04:13:06", makeAge(time.Since(timeOnlyLocal))},
// relative
{"15s", makeAge(15 * time.Second)},
{"1m15s", makeAge(75 * time.Second)},
{"30 seconds", makeAge(30 * time.Second)},
{"1 minute 30 seconds", makeAge(90 * time.Second)},
{"10m ago", makeAge(10 * time.Minute)},
{"5 days ago", makeAge(5 * 24 * time.Hour)},
// localized
{"vor 3 Tagen", makeAge(3 * 24 * time.Hour)},
{"3 jours", makeAge(3 * 24 * time.Hour)},
// future date (negative age)
{"in 3 days", makeAge(-1 * 3 * 24 * time.Hour)},
// error
{"this is not a date", `failed to parse "this is not a date": unknown format`},
// regression
{"1771548534964" /* event-ts */, makeAge(time.Since(time.UnixMilli(1771548627000)) /* test-ts */ + 92036*time.Millisecond /* skew */)},
// when the timestamp is a JSON number, it becomes scientific notation after being stringified!
{"1.771548534964e+12" /* event-ts */, makeAge(time.Since(time.UnixMilli(1771548627000)) /* test-ts */ + 92036*time.Millisecond /* skew */)},
{"1.771548534964e+9" /* event-ts */, makeAge(time.Since(time.Unix(1771548627, 0)) /* test-ts */ + 92036*time.Millisecond /* skew */)},
}

for _, test := range tests {
t.Run(test.timestamp, func(t *testing.T) {
actual := parseAge(test.timestamp)
if strings.HasPrefix(test.expected, "age: ") {
assertAgeInRange(t, actual, test.expected)
} else if actual != test.expected {
t.Errorf("expected '%s', got '%s'", test.expected, actual)
}
})
}
}
12 changes: 10 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20250521085720-bf793ab67800 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/markusmobius/go-dateparser v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
Expand All @@ -82,7 +87,10 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/wasilibs/go-re2 v1.10.0 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
Expand All @@ -93,8 +101,8 @@ require (
golang.org/x/image v0.35.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/protobuf v1.36.11 // indirect
Expand Down
Loading