trifle-stats 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 053aa86693f158ba5a395ab80396f0b3274bff69481ec80dd4335e782d000bfe
4
- data.tar.gz: 86be7c68e870d4fe38eac8dcae73634d8237a5334c8071c610932b22b57c735d
3
+ metadata.gz: 70c1c7a45dc8833c358ebab3d93a55e15f1f42f4459eb0d5fab14b7383de0a04
4
+ data.tar.gz: c09506de6ef9cd69b35733a616d43a09aa62ad8c6013f32552d1a0b7dcf870f6
5
5
  SHA512:
6
- metadata.gz: cfc8e1a9b317808a37acc6799d87a99d856fe4e4f4b792ac223d96f4ac500b2e6c77e25a5e99e1861047524984cf7ce2bd691a6d930404384b973a173ed49e99
7
- data.tar.gz: 383c36c748fa4266b2d741054f1f22a35baff5806ccb5ab169d3a0cf49c9fabad2fae97e64acb86fe2fbd534600acad0fe5ee6edac7a01faea6340c5b1eb1c13
6
+ metadata.gz: c980da761d8029b971ad9520d898d1971ba164c7cb2a1d7ffdbad60c67820cbb7eecf0ca25fa547518f06615dbe71411f376e20f2672dac1c9fca5c5dddaa5aa
7
+ data.tar.gz: 23266f716645654a5b642abf1558bbc85667b069dfb1caa98cfa1cc0899fd732769d2e0628107d06397b9b50996630b24a8f4ad6b634c5a8851d9984a66c7ad0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- trifle-stats (2.6.0)
4
+ trifle-stats (2.7.0)
5
5
  tzinfo (~> 2.0)
6
6
 
7
7
  GEM
@@ -60,32 +60,8 @@ module Trifle
60
60
  end
61
61
 
62
62
  def direct_write(operation:, key:, at:, values:, untracked: false)
63
- body = gzip(JSON.generate(
64
- operation: operation.to_s,
65
- key: key,
66
- at: at.to_time.iso8601(6),
67
- values: values,
68
- untracked: untracked
69
- ))
70
- request = Net::HTTP::Post.new(ENDPOINT)
71
- request['Authorization'] = "Bearer #{token}"
72
- request['X-Trifle-Source-Id'] = project_id
73
- request['Content-Type'] = 'application/json'
74
- request['Accept'] = 'application/json'
75
- request['Content-Encoding'] = 'gzip'
76
- request['User-Agent'] = "trifle-stats-ruby/#{Trifle::Stats::VERSION}"
77
- request.body = body
78
-
79
- response = @transport.call(uri: ENDPOINT, request: request, timeout: TIMEOUT)
80
- return true if response.code.to_i.between?(200, 299)
81
-
82
- response_body = response.body.to_s.byteslice(0, ERROR_BODY_LIMIT)
83
- raise Error.new(
84
- "Trifle API returned HTTP #{response.code}",
85
- status: response.code.to_i,
86
- response_body: response_body,
87
- retry_after: response['Retry-After']
88
- )
63
+ payload = api_payload(operation, key, at, values, untracked)
64
+ deliver(build_request(payload))
89
65
  rescue Error
90
66
  raise
91
67
  rescue StandardError => e
@@ -114,6 +90,49 @@ module Trifle
114
90
 
115
91
  private
116
92
 
93
+ def api_payload(operation, key, at, values, untracked)
94
+ {
95
+ operation: operation.to_s,
96
+ key: key,
97
+ at: at.to_time.iso8601(6),
98
+ values: values,
99
+ untracked: untracked
100
+ }
101
+ end
102
+
103
+ def build_request(payload)
104
+ request = Net::HTTP::Post.new(ENDPOINT, request_headers)
105
+ request.body = gzip(JSON.generate(payload))
106
+ request
107
+ end
108
+
109
+ def request_headers
110
+ {
111
+ 'Authorization' => "Bearer #{token}",
112
+ 'X-Trifle-Source-Id' => project_id,
113
+ 'Content-Type' => 'application/json',
114
+ 'Accept' => 'application/json',
115
+ 'Content-Encoding' => 'gzip',
116
+ 'User-Agent' => "trifle-stats-ruby/#{Trifle::Stats::VERSION}"
117
+ }
118
+ end
119
+
120
+ def deliver(request)
121
+ response = @transport.call(uri: ENDPOINT, request: request, timeout: TIMEOUT)
122
+ return true if response.code.to_i.between?(200, 299)
123
+
124
+ raise_response_error(response)
125
+ end
126
+
127
+ def raise_response_error(response)
128
+ raise Error.new(
129
+ "Trifle API returned HTTP #{response.code}",
130
+ status: response.code.to_i,
131
+ response_body: response.body.to_s.byteslice(0, ERROR_BODY_LIMIT),
132
+ retry_after: response['Retry-After']
133
+ )
134
+ end
135
+
117
136
  def gzip(value)
118
137
  output = StringIO.new
119
138
  Zlib::GzipWriter.wrap(output) { |writer| writer.write(value) }
@@ -26,7 +26,8 @@ module Trifle
26
26
  item = from.dup
27
27
  while item <= to
28
28
  list << item
29
- item = new(item, config: config).add(offset, unit)
29
+ candidate = new(item, config: config).add(offset, unit)
30
+ item = new(candidate, config: config).floor(offset, unit)
30
31
  end
31
32
  list
32
33
  end
@@ -49,53 +50,21 @@ module Trifle
49
50
 
50
51
  case unit
51
52
  when :second
52
- time + offset
53
+ add_elapsed(offset)
53
54
  when :minute
54
- time + (offset * 60)
55
+ add_elapsed(offset * 60)
55
56
  when :hour
56
- time + (offset * 3600)
57
+ add_elapsed(offset * 3600)
57
58
  when :day
58
- time + (offset * 86_400)
59
+ add_days(calendar_offset(offset))
59
60
  when :week
60
- time + (offset * 604_800)
61
+ add_days(calendar_offset(offset) * 7)
61
62
  when :month
62
- # Handle months carefully due to varying lengths
63
- result = time
64
- offset.times do
65
- result = add_one_month(result)
66
- end
67
- result
63
+ add_months(calendar_offset(offset))
68
64
  when :quarter
69
- # Add 3 months for each quarter
70
- result = time
71
- (offset * 3).times do
72
- result = add_one_month(result)
73
- end
74
- result
65
+ add_months(calendar_offset(offset) * 3)
75
66
  when :year
76
- # Handle years carefully due to leap years
77
- begin
78
- Time.new(
79
- time.year + offset,
80
- time.month,
81
- time.day,
82
- time.hour,
83
- time.min,
84
- time.sec,
85
- config.tz.utc_offset
86
- )
87
- rescue ArgumentError
88
- # Handle edge cases like Feb 29 in non-leap years
89
- Time.new(
90
- time.year + offset,
91
- time.month,
92
- [time.day, days_in_month(time.year + offset, time.month)].min,
93
- time.hour,
94
- time.min,
95
- time.sec,
96
- time.utc_offset
97
- )
98
- end
67
+ add_years(calendar_offset(offset))
99
68
  end
100
69
  end
101
70
 
@@ -104,89 +73,193 @@ module Trifle
104
73
  raise ArgumentError, 'Segment size must be positive' unless offset.positive?
105
74
  raise ArgumentError, "Invalid unit: #{unit}" unless Trifle::Stats::Nocturnal::UNIT_MAP.values.include?(unit)
106
75
 
76
+ source = localized_time
77
+
107
78
  case unit
108
79
  when :second
109
80
  # Floor to second segment boundary
110
- total_seconds = time.sec
81
+ total_seconds = source.sec
111
82
  floored_seconds = (total_seconds / offset) * offset
112
- Time.new(time.year, time.month, time.day, time.hour, time.min, floored_seconds, time.utc_offset)
83
+ subtract_elapsed(source, (total_seconds - floored_seconds) + source.subsec)
113
84
 
114
85
  when :minute
115
86
  # Floor to minute segment boundary (segments start from beginning of hour)
116
- minutes_from_hour_start = time.min
87
+ minutes_from_hour_start = source.min
117
88
  floored_minutes = (minutes_from_hour_start / offset) * offset
118
- Time.new(time.year, time.month, time.day, time.hour, floored_minutes, 0, time.utc_offset)
89
+ seconds = ((minutes_from_hour_start - floored_minutes) * 60) + source.sec + source.subsec
90
+ subtract_elapsed(source, seconds)
119
91
 
120
92
  when :hour
121
- # Floor to hour segment boundary (segments start from beginning of day)
122
- hours_from_day_start = time.hour
123
- floored_hours = (hours_from_day_start / offset) * offset
124
- Time.new(time.year, time.month, time.day, floored_hours, 0, 0, time.utc_offset)
93
+ # Hour segments are elapsed durations anchored at local day start.
94
+ day_start = local_midnight(source.to_date, source)
95
+ segment = offset * 3600
96
+ elapsed = source - day_start
97
+ add_elapsed_to(day_start, (elapsed / segment).floor * segment)
125
98
 
126
99
  when :day
127
100
  # Floor to day segment boundary (segments start from beginning of year)
128
- days_from_year_start = time.yday - 1 # yday is 1-indexed, we want 0-indexed
101
+ days_from_year_start = source.yday - 1 # yday is 1-indexed, we want 0-indexed
129
102
  floored_days = (days_from_year_start / offset) * offset
130
- year_start = Time.new(time.year, 1, 1, 0, 0, 0, time.utc_offset)
131
- result_date = year_start.to_date + floored_days
132
- Time.new(result_date.year, result_date.month, result_date.day, 0, 0, 0, time.utc_offset)
103
+ result_date = Date.new(source.year, 1, 1) + floored_days
104
+ local_midnight(result_date, source)
133
105
 
134
106
  when :week
135
107
  # Floor to week segment boundary (segments start from beginning of year)
136
- year_start = Time.new(time.year, 1, 1, 0, 0, 0, time.utc_offset)
108
+ year_start_date = Date.new(source.year, 1, 1)
137
109
 
138
110
  # Find the first week boundary of the year based on week_start
139
- week_start_offset = DAYS_INTO_WEEK.fetch(
140
- config.beginning_of_week
141
- )
142
- year_start_wday = year_start.wday
111
+ week_start_offset = DAYS_INTO_WEEK.fetch(config.beginning_of_week)
112
+ year_start_wday = year_start_date.wday
143
113
  days_to_first_week_start = (week_start_offset - year_start_wday) % 7
144
- first_week_start = year_start.to_date + days_to_first_week_start
145
- first_week_start_time = Time.new(first_week_start.year, first_week_start.month, first_week_start.day, 0, 0, 0, time.utc_offset) # rubocop:disable Layout/LineLength
114
+ first_week_start = year_start_date + days_to_first_week_start
146
115
 
147
116
  # If current time is before first week boundary, use year start
148
- if time < first_week_start_time
149
- year_start
117
+ if source.to_date < first_week_start
118
+ local_midnight(year_start_date, source)
150
119
  else
151
- # Calculate weeks since first week start
152
- weeks_since_first = ((time - first_week_start_time) / (7 * 86_400)).to_i
120
+ weeks_since_first = ((source.to_date - first_week_start).to_i / 7)
153
121
  floored_weeks = (weeks_since_first / offset) * offset
154
122
 
155
123
  result_date = first_week_start + (floored_weeks * 7)
156
- Time.new(result_date.year, result_date.month, result_date.day, 0, 0, 0, time.utc_offset)
124
+ local_midnight(result_date, source)
157
125
  end
158
126
 
159
127
  when :month
160
128
  # Floor to month segment boundary (from start of year)
161
- months_from_jan = time.month - 1 # 0-indexed
129
+ months_from_jan = source.month - 1 # 0-indexed
162
130
  floored_months = (months_from_jan / offset) * offset
163
- Time.new(time.year, floored_months + 1, 1, 0, 0, 0, time.utc_offset)
131
+ date = Date.new(source.year, floored_months + 1, 1)
132
+ local_midnight(date, source)
164
133
 
165
134
  when :quarter
166
135
  # Floor to quarter segment boundary
167
- current_quarter = ((time.month - 1) / 3) # 0-indexed quarters
136
+ current_quarter = ((source.month - 1) / 3) # 0-indexed quarters
168
137
  floored_quarters = (current_quarter / offset) * offset
169
138
  quarter_start_month = (floored_quarters * 3) + 1
170
- Time.new(time.year, quarter_start_month, 1, 0, 0, 0, time.utc_offset)
139
+ date = Date.new(source.year, quarter_start_month, 1)
140
+ local_midnight(date, source)
171
141
 
172
142
  when :year
173
143
  # Floor to year segment boundary
174
- floored_years = (time.year / offset) * offset
175
- Time.new(floored_years, 1, 1, 0, 0, 0, time.utc_offset)
144
+ floored_years = (source.year / offset) * offset
145
+ date = Date.new(floored_years, 1, 1)
146
+ local_midnight(date, source)
176
147
  end
177
148
  end
178
149
 
179
150
  private
180
151
 
181
- def add_one_month(time) # rubocop:disable Metrics/AbcSize
182
- next_month = time.month == 12 ? 1 : time.month + 1
183
- next_year = time.month == 12 ? time.year + 1 : time.year
152
+ def add_elapsed(seconds)
153
+ add_elapsed_to(time, seconds)
154
+ end
155
+
156
+ def add_elapsed_to(source, seconds)
157
+ config.tz.utc_to_local((source + seconds).getutc)
158
+ end
159
+
160
+ def subtract_elapsed(source, seconds)
161
+ config.tz.utc_to_local((source - seconds).getutc)
162
+ end
163
+
164
+ def add_days(days)
165
+ source = localized_time
166
+ local_time_for_source(source.to_date + days, source)
167
+ end
168
+
169
+ def add_months(months)
170
+ source = localized_time
171
+ total_months = (source.year * 12) + (source.month - 1) + months
172
+ year, month_index = total_months.divmod(12)
173
+ month = month_index + 1
174
+ day = [source.day, days_in_month(year, month)].min
175
+
176
+ local_time_for_source(Date.new(year, month, day), source)
177
+ end
178
+
179
+ def add_years(years)
180
+ source = localized_time
181
+ year = source.year + years
182
+ day = [source.day, days_in_month(year, source.month)].min
183
+
184
+ local_time_for_source(Date.new(year, source.month, day), source)
185
+ end
186
+
187
+ def localized_time
188
+ config.tz.utc_to_local(time.getutc)
189
+ end
190
+
191
+ def calendar_offset(offset)
192
+ return offset if offset.is_a?(Integer)
193
+
194
+ raise ArgumentError, 'Offset must be an integer for calendar units'
195
+ end
196
+
197
+ def local_midnight(date, source)
198
+ local_time(date, source: source, hour: 0, minute: 0, second: 0)
199
+ end
200
+
201
+ def local_time_for_source(date, source)
202
+ local_time(
203
+ date,
204
+ source: source,
205
+ hour: source.hour,
206
+ minute: source.min,
207
+ second: source.sec,
208
+ sub_second: source.subsec
209
+ )
210
+ end
184
211
 
185
- # Handle the case where the day doesn't exist in the next month (e.g., Jan 31 -> Feb 31)
186
- max_day = days_in_month(next_year, next_month)
187
- day = [time.day, max_day].min
212
+ def local_time(date, source:, **clock)
213
+ clock = { hour: 0, minute: 0, second: 0, sub_second: 0 }.merge(clock)
214
+ config.tz.local_time(
215
+ date.year, date.month, date.day,
216
+ clock[:hour], clock[:minute], clock[:second], clock[:sub_second], source.dst?
217
+ )
218
+ rescue TZInfo::AmbiguousTime
219
+ resolve_ambiguous_time(date, clock, source)
220
+ rescue TZInfo::PeriodNotFound
221
+ resolve_missing_time(date, clock, source)
222
+ end
223
+
224
+ def resolve_ambiguous_time(date, clock, source)
225
+ timestamp = local_timestamp(date, clock)
226
+ periods = config.tz.periods_for_local(timestamp)
227
+ period = periods.find { |candidate| candidate.observed_utc_offset == source.utc_offset } || periods.first
228
+ config.tz.utc_to_local(Time.at(timestamp.to_r - period.observed_utc_offset).utc)
229
+ end
230
+
231
+ def resolve_missing_time(date, clock, source)
232
+ timestamp = local_timestamp(date, clock)
233
+ transition = missing_time_transition(timestamp)
234
+ raise TZInfo::PeriodNotFound, timestamp.to_s unless transition
235
+
236
+ gap = transition.offset.observed_utc_offset - transition.previous_offset.observed_utc_offset
237
+ shifted = timestamp + gap
238
+ shifted_clock = { hour: shifted.hour, minute: shifted.min, second: shifted.sec, sub_second: shifted.subsec }
239
+ local_time(shifted.to_date, source: source, **shifted_clock)
240
+ end
241
+
242
+ def missing_time_transition(timestamp)
243
+ config.tz.transitions_up_to(timestamp + 172_800, timestamp - 172_800).find do |transition|
244
+ missing_time_transition?(timestamp, transition)
245
+ end
246
+ end
247
+
248
+ def missing_time_transition?(timestamp, transition)
249
+ previous_offset = transition.previous_offset.observed_utc_offset
250
+ next_offset = transition.offset.observed_utc_offset
251
+ return false unless next_offset > previous_offset
252
+
253
+ start_at = transition.at.to_time + previous_offset
254
+ end_at = transition.at.to_time + next_offset
255
+ timestamp >= start_at && timestamp < end_at
256
+ end
188
257
 
189
- Time.new(next_year, next_month, day, time.hour, time.min, time.sec, config.tz.utc_offset)
258
+ def local_timestamp(date, clock)
259
+ Time.utc(
260
+ date.year, date.month, date.day,
261
+ clock[:hour], clock[:minute], clock[:second] + clock[:sub_second]
262
+ )
190
263
  end
191
264
 
192
265
  def days_in_month(year, month)
@@ -26,16 +26,30 @@ module Trifle
26
26
  end
27
27
 
28
28
  def perform
29
- if config.driver.respond_to?(:direct_write)
30
- return config.driver.direct_write(
31
- operation: :track,
32
- key: key,
33
- at: @at,
34
- values: values,
35
- untracked: @untracked
36
- )
37
- end
29
+ return direct_write if direct_write?
30
+
31
+ buffered_write
32
+ end
33
+
34
+ def tracking_key
35
+ @untracked ? '__untracked__' : nil
36
+ end
37
+
38
+ private
39
+
40
+ def direct_write?
41
+ config.driver.respond_to?(:direct_write)
42
+ end
38
43
 
44
+ def direct_write
45
+ config.driver.direct_write(**direct_payload)
46
+ end
47
+
48
+ def direct_payload
49
+ { operation: :track, key: key, at: @at, values: values, untracked: @untracked }
50
+ end
51
+
52
+ def buffered_write
39
53
  payload = {
40
54
  keys: config.granularities.map { |granularity| key_for(granularity: granularity) },
41
55
  values: values
@@ -48,12 +62,6 @@ module Trifle
48
62
  end
49
63
  end
50
64
 
51
- def tracking_key
52
- @untracked ? '__untracked__' : nil
53
- end
54
-
55
- private
56
-
57
65
  def localized_time(time)
58
66
  base_time = time.is_a?(Time) ? time : time.to_time
59
67
  config.tz.utc_to_local(base_time.getutc)
@@ -26,16 +26,30 @@ module Trifle
26
26
  end
27
27
 
28
28
  def perform
29
- if config.driver.respond_to?(:direct_write)
30
- return config.driver.direct_write(
31
- operation: :assert,
32
- key: key,
33
- at: @at,
34
- values: values,
35
- untracked: @untracked
36
- )
37
- end
29
+ return direct_write if direct_write?
30
+
31
+ buffered_write
32
+ end
33
+
34
+ def tracking_key
35
+ @untracked ? '__untracked__' : nil
36
+ end
37
+
38
+ private
39
+
40
+ def direct_write?
41
+ config.driver.respond_to?(:direct_write)
42
+ end
38
43
 
44
+ def direct_write
45
+ config.driver.direct_write(**direct_payload)
46
+ end
47
+
48
+ def direct_payload
49
+ { operation: :assert, key: key, at: @at, values: values, untracked: @untracked }
50
+ end
51
+
52
+ def buffered_write
39
53
  payload = {
40
54
  keys: config.granularities.map { |granularity| key_for(granularity: granularity) },
41
55
  values: values
@@ -48,12 +62,6 @@ module Trifle
48
62
  end
49
63
  end
50
64
 
51
- def tracking_key
52
- @untracked ? '__untracked__' : nil
53
- end
54
-
55
- private
56
-
57
65
  def localized_time(time)
58
66
  base_time = time.is_a?(Time) ? time : time.to_time
59
67
  config.tz.utc_to_local(base_time.getutc)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Trifle
4
4
  module Stats
5
- VERSION = '2.6.0'
5
+ VERSION = '2.7.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trifle-stats
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.0
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jozef Vaclavik
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler