Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/relative-date-month-clamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": patch
---

Relative date filters in local flag evaluation now clamp the day after every month or year step, as the SDK spec requires. `2m` on March 31 resolves to January 28 instead of January 31, and `4y` on February 29, 2024 to February 28, 2020.
28 changes: 26 additions & 2 deletions lib/posthog/feature_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -548,16 +548,40 @@ def self.relative_date_parse_for_feature_flag_matching(value)
when 'w'
parsed_dt = parsed_dt.prev_day(number * 7)
when 'm'
parsed_dt = parsed_dt.prev_month(number)
parsed_dt = subtract_months(parsed_dt, number)
when 'y'
parsed_dt = parsed_dt.prev_year(number)
parsed_dt = subtract_years(parsed_dt, number)
else
return nil
end

parsed_dt
end

# Steps back one month at a time and clamps the day after every step, as the
# SDK spec requires: March 31 minus two months is January 28. A single
# prev_month(2) clamps once and lands on January 31.
def self.subtract_months(date_time, months)
steps = 0
# Every month has at least 28 days, so once the day is 28 or less no step
# can clamp it again and the rest can be done in one jump.
while steps < months && date_time.day > 28
date_time = date_time.prev_month
steps += 1
end
date_time.prev_month(months - steps)
end
private_class_method :subtract_months

# Years work the same way. Only February 29 can clamp, and only on the first
# step, so one step followed by a single jump matches stepping every year.
def self.subtract_years(date_time, years)
return date_time if years.zero?

date_time.prev_year.prev_year(years - 1)
end
private_class_method :subtract_years

# Parse a single semver numeric identifier, rejecting empty, non-digit, or
# leading-zero values per semver 2.0.0 §2. `reason` names the owner of the value:
# UNSUPPORTED_DEFINITION for a filter value, MISSING_CONTEXT for a value the call passed.
Expand Down
23 changes: 23 additions & 0 deletions spec/posthog/feature_flag_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2537,6 +2537,29 @@ module PostHog
end
end

it 'clamps month and year lookbacks after every step' do
[
[[2025, 3, 31], '1m', [2025, 2, 28]],
[[2025, 5, 31], '1m', [2025, 4, 30]],
[[2024, 3, 31], '1m', [2024, 2, 29]],
[[2025, 3, 31], '2m', [2025, 1, 28]],
[[2025, 3, 31], '13m', [2024, 2, 28]],
[[2025, 12, 31], '10m', [2025, 2, 28]],
[[2019, 1, 29], '12m', [2018, 1, 28]],
[[2019, 1, 29], '1y', [2018, 1, 29]],
[[2025, 3, 31], '0m', [2025, 3, 31]],
[[2024, 2, 29], '1y', [2023, 2, 28]],
[[2024, 2, 29], '4y', [2020, 2, 28]],
[[2024, 2, 29], '0y', [2024, 2, 29]]
].each do |now, value, expected|
Timecop.freeze(Time.gm(*now, 12, 0, 0)) do
expect(
FeatureFlagsPoller.relative_date_parse_for_feature_flag_matching(value)
).to eq(DateTime.new(*expected, 12, 0, 0, Rational(0, 24))), "#{value} from #{now.join('-')}"
end
end
end

it 'test year parsing' do
Timecop.freeze(Time.gm(2020, 1, 1, 12, 1, 20, 134_000, 0)) do
expect(
Expand Down
Loading