Files
zeekayandHanzo Dev 99724b95b2 go: the flag evaluator, ported from the Rust staticlib to pure Go
cloud linked a Rust staticlib (native/flags) over cgo to decide flags. That
made every consumer of the flags path a cgo build, needed a prebuilt
libhanzo_flags.a on the link line, and put a second language in the release
pipeline for one pure function.

This is that function in Go, with no dependencies at all — the rollout hash,
the full property-operator set, semver ranges, the date operators, and the
serde-compatible value rendering the operators compare through.

The rollout hash decides which identities fall inside a percentage, so drift
would split cohorts differently here than in the rest of the fleet. The port is
therefore pinned to the Rust implementation's own answers: testdata/cases.json
is a 621-case table covering every operator, eight rollout percentages against
forty identities each, multivariate bucketing, group aggregation and the
numeric/escaping edges; testdata/oracle.json is what the Rust evaluator
returned for all of them; parity_test.go asserts this package still agrees.

Four deliberate mutations confirm the table has teeth: shifting the hash by one
bit (27 cases), changing the variant salt (27), dropping case folding from
exact matching (2), and dropping the '+' from positive float exponents (4).

Two divergences the port found and had to reproduce rather than fix: an integer
JSON literal and a float one render differently forever (1e3 is 1000.0, never
1000), and distinct_id must be present but may be empty.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 12:15:36 -07:00

225 lines
6.4 KiB
Go

package flags
// Date parsing for the is_date_* operators.
//
// The reference tries, in order: a relative offset ("-3d"), then the
// `dateparser` crate, then a bare ISO datetime read as UTC. Two of its
// behaviours are load-bearing and surprising, so they are reproduced rather
// than tidied up:
//
// - a datetime with no offset is read in the process's LOCAL zone, so the
// same definition can decide differently on two hosts
// - a date-only value ("2021-05-06") splices in the current wall-clock
// time-of-day, so it is not even stable across two calls in one process
//
// Only dateparser's deterministic families are reproduced (unix timestamps,
// RFC 3339, "Y-m-d H:M[:S]", and bare dates). Its locale-ish families —
// RFC 2822, month names, slash and dot orders, MySQL log and Chinese formats —
// are not: every one of them is date-only, hence non-deterministic by
// construction, and none is reachable from a flag definition anyone can rely on.
import (
"math"
"regexp"
"strconv"
"time"
)
var (
reRelative = regexp.MustCompile(`^-?([0-9]+)([hdwmy])$`)
reUnix = regexp.MustCompile(`^[0-9]{10,19}$`)
reYMDHMS = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}(:[0-9]{2})?(\.[0-9]{1,9})?\s*(am|pm|AM|PM)?$`)
reYMD = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
)
// parseRelativeDate reads "-3d" / "3d" style offsets back from now. Hours, days
// and weeks are fixed durations; months and years step calendar-wise, clamping
// the day at every step (so three months back from 31 March is 28 December,
// not 31 December).
func parseRelativeDate(s string, now time.Time) (time.Time, bool) {
m := reRelative.FindStringSubmatch(s)
if m == nil {
return time.Time{}, false
}
n, err := strconv.Atoi(m[1])
if err != nil || n >= 10000 { // guard against overflow, as the reference does
return time.Time{}, false
}
switch m[2] {
case "h":
return now.Add(-time.Duration(n) * time.Hour), true
case "d":
return now.AddDate(0, 0, -n), true
case "w":
return now.AddDate(0, 0, -7*n), true
case "m":
t := now
for i := 0; i < n; i++ {
y, mo := t.Year(), int(t.Month())-1
if mo == 0 {
y, mo = y-1, 12
}
day := min(t.Day(), daysIn(y, mo))
t = time.Date(y, time.Month(mo), day, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
}
return t, true
default: // "y"
t := now
for i := 0; i < n; i++ {
y := t.Year() - 1
day := t.Day()
if t.Month() == time.February && day == 29 && !leap(y) {
day = 28
}
t = time.Date(y, t.Month(), day, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
}
return t, true
}
}
func leap(y int) bool { return y%4 == 0 && (y%100 != 0 || y%400 == 0) }
func daysIn(y, m int) int {
switch m {
case 2:
if leap(y) {
return 29
}
return 28
case 4, 6, 9, 11:
return 30
}
return 31
}
// parseDateString is the reference's chain: relative offset, then dateparser,
// then a bare ISO datetime interpreted as UTC.
func parseDateString(s string) (time.Time, bool) {
if t, ok := parseRelativeDate(s, time.Now().UTC()); ok {
return t, true
}
if t, ok := dateparse(s); ok {
return t, true
}
for _, layout := range []string{"2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05"} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t, true
}
}
return time.Time{}, false
}
// dateparse reproduces the deterministic part of the dateparser crate.
func dateparse(s string) (time.Time, bool) {
// Unix timestamps, but only at the three lengths the crate accepts.
if reUnix.MatchString(s) {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return time.Time{}, false
}
switch len(s) {
case 10:
return time.Unix(n, 0).UTC(), true
case 13:
return time.UnixMilli(n).UTC(), true
case 19:
return time.Unix(0, n).UTC(), true
}
return time.Time{}, false
}
// The remaining families all begin with a year-month.
if !isYearMonthPrefix(s) {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t.UTC(), true
}
if reYMDHMS.MatchString(s) {
for _, layout := range []string{
"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02 15:04:05.999999999",
"2006-01-02 03:04:05 pm", "2006-01-02 03:04 pm",
} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.UTC(), true
}
}
}
if reYMD.MatchString(s) {
// Date-only: the crate fills the time from the current local clock.
now := time.Now().In(time.Local)
d, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return time.Time{}, false
}
return time.Date(d.Year(), d.Month(), d.Day(), now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.Local).UTC(), true
}
return time.Time{}, false
}
func isYearMonthPrefix(s string) bool {
if len(s) < 7 {
return false
}
for i, c := range []byte(s[:7]) {
if i == 4 {
if c != '-' {
return false
}
continue
}
if c < '0' || c > '9' {
return false
}
}
return true
}
// propertyDate reads the date a property carries. A numeric string is a unix
// timestamp BEFORE it is anything else — which is why a 13-digit millisecond
// stamp means one instant as a property value and another as a filter value.
func propertyDate(v value) (time.Time, bool) {
switch v.k {
case kStr:
if f, err := strconv.ParseFloat(v.s, 64); err == nil {
return floatTimestamp(f)
}
return parseDateString(v.s)
case kNum:
f, ok := v.asF64()
if !ok {
return time.Time{}, false
}
return floatTimestamp(f)
}
return time.Time{}, false
}
// floatTimestamp splits seconds-since-epoch into whole seconds and nanoseconds.
// The fractional part is taken with the sign of the value and then cast to an
// unsigned count, which saturates at zero — so a negative fraction contributes
// nothing rather than moving the instant backwards.
func floatTimestamp(f float64) (time.Time, bool) {
if math.IsNaN(f) || math.IsInf(f, 0) {
return time.Time{}, false
}
secs := math.Floor(f)
if secs < math.MinInt64 || secs > math.MaxInt64 {
return time.Time{}, false
}
nanos := math.Round(math.Mod(f, 1) * 1e9)
switch {
case nanos < 0:
nanos = 0
case nanos > math.MaxUint32:
nanos = math.MaxUint32
}
if nanos >= 2e9 { // chrono rejects a nanosecond count this large
return time.Time{}, false
}
t := time.Unix(int64(secs), int64(nanos)).UTC()
if t.Year() < 1 || t.Year() > 9999 {
return time.Time{}, false
}
return t, true
}