fluent-plugin-time-cutoff 0.2.0 → 0.4.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: 77b479a1cb54f5eb1a189032c5c0acfde4a99f0f813afb800ec84d39a4e09e2e
4
- data.tar.gz: 3d27e8cee579842f6705467ddbd8577f47aa72db134f592a82676c01ef758166
3
+ metadata.gz: a52993553c1cdc40bb6800791120e5248662a1f84546bf13b686c90c16f5c35d
4
+ data.tar.gz: c58051186b9056ee4fe0b6aa25850e11d1013de01038cca983323e525e551bdd
5
5
  SHA512:
6
- metadata.gz: 12ea75e58112114c23637be4f19083324040a64bd1528f9628da85077316c52b285aaafd54162a50421da9eff4df4e02d08754399a20cde5f87f374ec357a258
7
- data.tar.gz: c4731fb66d9b66fc6778a5a614e5534d2f600f61c15ab26adaa75687fdcede90ebccf9714a97b1be69d27761ecf412d2f66917834e9e46ecbb211d365a09d381
6
+ metadata.gz: 7b2c8c998860001c4142850878506fa29978fd996049019babdc7331dc74b6889c90a1caa55344075c4d399e883cdf384e9e7f2f3f9b8f3c7ba07c38c181a45c
7
+ data.tar.gz: 1be548ff25ac8d3e9db3314a3c01a36404c7c77cae535f1857366deb87ce2b62199913b84ffd3ffbf88c2de86a421c0cb5f4f936b722f8835d13cbda2064982b
data/README.md CHANGED
@@ -104,14 +104,22 @@ $ bundle
104
104
 
105
105
  old_cutoff 24h
106
106
  old_action pass
107
- old_log true
107
+ old_log full
108
108
 
109
109
  new_cutoff 24h
110
110
  new_action pass
111
- new_log true
111
+ new_log full
112
112
 
113
113
  source_time_key source_time
114
114
  source_time_format iso8601
115
+
116
+ bad_time_action replace_timestamp
117
+ bad_time_log full
118
+ bad_time_key source_time_raw
119
+
120
+ time_key_conflict skip
121
+
122
+ metrics false
115
123
  </filter>
116
124
 
117
125
  ```
@@ -120,6 +128,8 @@ $ bundle
120
128
 
121
129
  Records older than this amount of time are deemed "old". Defaults to 24 hours.
122
130
 
131
+ Must not be negative.
132
+
123
133
  Default value: `86400`.
124
134
 
125
135
  ### old_action (enum) (optional)
@@ -130,16 +140,22 @@ Available values: pass, replace_timestamp, drop
130
140
 
131
141
  Default value: `pass`.
132
142
 
133
- ### old_log (bool) (optional)
143
+ ### old_log (enum) (optional)
134
144
 
135
- Log the "old" messages to Fluentd's own log. Defaults to "true".
145
+ Log the "old" messages to Fluentd's own log. Defaults to "full".
136
146
 
137
- Default value: `true`.
147
+ Available values: none (or false/no), metadata, full (or true/yes)
148
+
149
+ See [Logging](#logging) for what each mode logs.
150
+
151
+ Default value: `full`.
138
152
 
139
153
  ### new_cutoff (time) (optional)
140
154
 
141
155
  Records newer than this amount of time are deemed "new". Defaults to 24 hours.
142
156
 
157
+ Must not be negative.
158
+
143
159
  Default value: `86400`.
144
160
 
145
161
  ### new_action (enum) (optional)
@@ -150,16 +166,22 @@ Available values: pass, replace_timestamp, drop
150
166
 
151
167
  Default value: `pass`.
152
168
 
153
- ### new_log (bool) (optional)
169
+ ### new_log (enum) (optional)
170
+
171
+ Log the "new" messages to Fluentd's own log. Defaults to "full".
154
172
 
155
- Log the "new" messages to Fluentd's own log. Defaults to "true".
173
+ Available values: none (or false/no), metadata, full (or true/yes)
156
174
 
157
- Default value: `true`.
175
+ See [Logging](#logging) for what each mode logs.
176
+
177
+ Default value: `full`.
158
178
 
159
179
  ### source_time_key (string) (optional)
160
180
 
161
181
  The key that will hold the original time (for :replace_timestamp action). Defaults to "source_time".
162
182
 
183
+ Must not be empty or equal to `bad_time_key`.
184
+
163
185
  Default value: `source_time`.
164
186
 
165
187
  ### source_time_format (enum) (optional)
@@ -170,7 +192,116 @@ Available values: epoch, epoch_float, iso8601
170
192
 
171
193
  Default value: `iso8601`.
172
194
 
195
+ ### bad_time_action (enum) (optional)
196
+
197
+ The action that will be performed to all messages with an unparseable time. Defaults to replacing it with current time.
198
+
199
+ Event times that aren't a `Fluent::EventTime` are parsed before the age check. Integers, floats, numeric strings and ISO 8601 datetime strings are accepted. ISO 8601 strings may use a space instead of `T` and may have a space before the timezone (e.g. `2025-04-08 14:43:14 +0300` or `2025-04-08 11:43:14 UTC`, as produced by Ruby's `Time#to_s`). Strings without a timezone are treated as local time. Any other time is deemed unparseable and is handled according to this action (and logged, unless `bad_time_log` is `none`).
200
+
201
+ Available values: replace_timestamp, drop
202
+
203
+ Default value: `replace_timestamp`.
204
+
205
+ ### bad_time_log (enum) (optional)
206
+
207
+ Log the messages with an unparseable time to Fluentd's own log. Defaults to "full".
208
+
209
+ Available values: none (or false/no), metadata, full (or true/yes)
210
+
211
+ See [Logging](#logging) for what each mode logs.
212
+
213
+ Default value: `full`.
214
+
215
+ ### bad_time_key (string) (optional)
216
+
217
+ The key that will hold the original unparseable time (for :replace_timestamp action). Defaults to "source_time_raw".
218
+
219
+ Must not be empty or equal to `source_time_key`.
220
+
221
+ The original time is stored as is if it's a string, or as its Ruby `#inspect` representation otherwise (e.g. `nil`), so this field always holds a string. It is kept separate from `source_time_key`, so that `source_time_key` always holds a value in `source_time_format` and doesn't cause type conflicts in downstream storage.
222
+
223
+ Default value: `source_time_raw`.
224
+
225
+ ### time_key_conflict (enum) (optional)
226
+
227
+ The action that will be performed when the record already has source_time_key or bad_time_key. Defaults to keeping the existing value.
228
+
229
+ This happens e.g. when a record passes through more than one `time_cutoff` filter, in which case the existing value is the true original time. The event time itself is replaced with current time in any case.
230
+
231
+ * `skip`: keep the existing value.
232
+ * `warn`: keep the existing value and log a warning to Fluentd's own log. The warning is logged even if the record's log mode is `none`, but includes the record only in `full` mode.
233
+ * `overwrite`: replace the existing value.
234
+
235
+ Available values: skip, warn, overwrite
236
+
237
+ Default value: `skip`.
238
+
239
+ ### metrics (bool) (optional)
240
+
241
+ Count caught records in a Prometheus counter (needs the prometheus-client gem). Defaults to "false".
242
+
243
+ See [Metrics](#metrics) for details.
244
+
245
+ Default value: `false`.
246
+
247
+ ## Logging
248
+
249
+ Each caught record is logged as a warning to Fluentd's own log, according to the `old_log`, `new_log` or `bad_time_log` mode:
250
+
251
+ * `none` (or `false`/`no`): nothing is logged.
252
+ * `metadata`: only the tag, the event time and its age (or the raw unparseable time) are logged, but not the record itself. Use it for records that may hold sensitive data, e.g. audit logs.
253
+ * `full` (or `true`/`yes`): the whole record is logged as well.
254
+
255
+ ```
256
+ # metadata
257
+ [warn]: [time_cutoff] Record caught [old, drop]: "vault.audit: 2025-04-07T14:43:24+03:00 (age 1d 0h 0m 0s)".
258
+ [warn]: [time_cutoff] Record caught [new, pass]: "vault.audit: 2025-04-08T16:43:29+03:00 (age -2h 0m 5s)".
259
+ [warn]: [time_cutoff] Record caught [bad_time, drop]: "vault.audit: "not-a-time"".
260
+
261
+ # full
262
+ [warn]: [time_cutoff] Record caught [old, drop]: "vault.audit: 2025-04-07T14:43:24+03:00 {"message":"hello"}".
263
+ [warn]: [time_cutoff] Record caught [bad_time, drop]: "vault.audit: "not-a-time" {"message":"hello"}".
264
+ ```
265
+
266
+ ## Metrics
267
+
268
+ With `metrics true`, caught records are counted in a Prometheus counter:
269
+
270
+ ```
271
+ fluentd_time_cutoff_records_total{plugin_id, worker_id, hostname, tag, reason, action}
272
+ ```
273
+
274
+ * `plugin_id`: the filter's `@id`. Give each `time_cutoff` filter a stable `@id` to tell them apart.
275
+ * `worker_id`, `hostname`: the Fluentd worker and host, so that series from multiple workers (e.g. on fluent-plugin-prometheus's `/aggregated_metrics` endpoint) and hosts don't collide.
276
+ * `tag`: the record's tag.
277
+ * `reason`: why the record was caught: `old`, `new` or `bad_time`.
278
+ * `action`: what was done to it: `pass`, `replace_timestamp` or `drop`.
279
+
280
+ Records inside the time window aren't counted. Records are counted regardless of their log mode, so the counter can replace per-record logging.
281
+
282
+ The counter is registered in the default registry of the [prometheus-client](https://github.com/prometheus/client_ruby) gem (version 2.1 or newer), which is what [fluent-plugin-prometheus](https://github.com/fluent/fluent-plugin-prometheus) serves, so adding its `prometheus` input is enough to expose it:
283
+
284
+ ``` xml
285
+ <source>
286
+ @type prometheus
287
+ </source>
288
+
289
+ <filter **>
290
+ @type time_cutoff
291
+ @id cutoff_main
292
+ metrics true
293
+ </filter>
294
+ ```
295
+
296
+ The prometheus-client gem is an optional dependency of this plugin. If it's not installed, enabling `metrics` only logs a warning on startup. Errors while updating the counter are logged once and never affect how records are processed.
297
+
298
+ Example query, records dropped per second for being too old, by tag:
299
+
300
+ ```
301
+ sum by (tag) (rate(fluentd_time_cutoff_records_total{reason="old", action="drop"}[5m]))
302
+ ```
303
+
173
304
  ## Copyright
174
305
 
175
- * Copyright(c) 2025 Qrator Labs and Serge Tkatchouk
306
+ * Copyright(c) 2025-2026 Qrator Labs and Serge Tkatchouk
176
307
  * License: MIT
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fluent/plugin/filter'
4
4
  require 'json'
5
+ require 'socket'
5
6
  require 'time'
6
7
 
7
8
  module Fluent
@@ -31,36 +32,136 @@ module Fluent
31
32
  :iso8601
32
33
  ].freeze
33
34
 
35
+ # Available actions for records with an unparseable time.
36
+ BAD_TIME_ACTIONS = [
37
+ # Replace time with "now" and pass:
38
+ :replace_timestamp,
39
+ # Drop record as invalid:
40
+ :drop
41
+ ].freeze
42
+
43
+ # Available actions for records that already have the key the original
44
+ # time should be put into (for :replace_timestamp action).
45
+ TIME_KEY_CONFLICT_ACTIONS = [
46
+ # Keep the existing value:
47
+ :skip,
48
+ # Keep the existing value and log a warning:
49
+ :warn,
50
+ # Replace the existing value:
51
+ :overwrite
52
+ ].freeze
53
+
54
+ # Available log modes for caught records.
55
+ LOG_MODES = [
56
+ # Do not log:
57
+ :none,
58
+ # Log tag, time and age (or the raw unparseable time) only:
59
+ :metadata,
60
+ # Log the whole record:
61
+ :full,
62
+ # Aliases for :none and :full, kept for compatibility with the
63
+ # boolean *_log options of earlier versions:
64
+ :false,
65
+ :no,
66
+ :true,
67
+ :yes
68
+ ].freeze
69
+
70
+ # Log modes the boolean aliases stand for.
71
+ LOG_MODE_ALIASES = { false: :none, no: :none, true: :full, yes: :full }.freeze
72
+
73
+ # Name and label names of the Prometheus counter for caught records.
74
+ METRIC_NAME = :fluentd_time_cutoff_records_total
75
+ METRIC_LABELS = %i[plugin_id worker_id hostname tag reason action].freeze
76
+
34
77
  # strftime() format string for :iso8601 time format.
35
78
  TIME_ISO8601 = '%FT%T%:z'
36
79
 
80
+ # Matches a "relaxed" ISO 8601 datetime string, which uses a space
81
+ # instead of "T" between the date and the time and/or has a space
82
+ # before the timezone (e.g. Ruby's Time#to_s: "2026-10-10 03:57:04 +0300"
83
+ # or "2026-10-10 00:57:04 UTC"), so that it can be rebuilt as a strict one.
84
+ ISO8601_RELAXED = /
85
+ \A
86
+ (?<date>\d{4}-\d{2}-\d{2})
87
+ [T\ ]
88
+ (?<time>\d{2}:\d{2}:\d{2}(?:\.\d+)?)
89
+ (?:\ ?(?<zone>Z|UTC|[+-]\d{2}(?::?\d{2})?))?
90
+ \z
91
+ /x.freeze
92
+
37
93
  Fluent::Plugin.register_filter('time_cutoff', self)
38
94
 
39
95
  desc 'Records older than this amount of time are deemed "old". Defaults to 24 hours.'
40
96
  config_param :old_cutoff, :time, default: 86_400 # 24 hours
41
97
  desc 'The action that will be performed to all "old" messages. Defaults to passthrough.'
42
98
  config_param :old_action, :enum, list: CUTOFF_ACTIONS, default: :pass
43
- desc 'Log the "old" messages to Fluentd\'s own log. Defaults to "true".'
44
- config_param :old_log, :bool, default: true
99
+ desc 'Log the "old" messages to Fluentd\'s own log: none (false/no), metadata or full (true/yes). Defaults to "full".'
100
+ config_param :old_log, :enum, list: LOG_MODES, default: :full
45
101
  desc 'Records newer than this amount of time are deemed "new". Defaults to 24 hours.'
46
102
  config_param :new_cutoff, :time, default: 86_400 # 24 hours
47
103
  desc 'The action that will be performed to all "new" messages. Defaults to passthrough.'
48
104
  config_param :new_action, :enum, list: CUTOFF_ACTIONS, default: :pass
49
- desc 'Log the "new" messages to Fluentd\'s own log. Defaults to "true".'
50
- config_param :new_log, :bool, default: true
105
+ desc 'Log the "new" messages to Fluentd\'s own log: none (false/no), metadata or full (true/yes). Defaults to "full".'
106
+ config_param :new_log, :enum, list: LOG_MODES, default: :full
51
107
  desc 'The key that will hold the original time (for :replace_timestamp action). Defaults to "source_time".'
52
108
  config_param :source_time_key, :string, default: 'source_time'
53
109
  desc 'Time format for the original timestamp field. Defaults to ISO8601-formatted string.'
54
110
  config_param :source_time_format, :enum, list: TIME_FORMATS, default: :iso8601
111
+ desc 'The action that will be performed to all messages with an unparseable time. Defaults to replacing it with current time.'
112
+ config_param :bad_time_action, :enum, list: BAD_TIME_ACTIONS, default: :replace_timestamp
113
+ desc 'Log the messages with an unparseable time to Fluentd\'s own log: none (false/no), metadata or full (true/yes). Defaults to "full".'
114
+ config_param :bad_time_log, :enum, list: LOG_MODES, default: :full
115
+ desc 'The key that will hold the original unparseable time (for :replace_timestamp action). Defaults to "source_time_raw".'
116
+ config_param :bad_time_key, :string, default: 'source_time_raw'
117
+ desc 'The action that will be performed when the record already has source_time_key or bad_time_key. Defaults to keeping the existing value.'
118
+ config_param :time_key_conflict, :enum, list: TIME_KEY_CONFLICT_ACTIONS, default: :skip
119
+ desc 'Count caught records in a Prometheus counter (needs the prometheus-client gem). Defaults to "false".'
120
+ config_param :metrics, :bool, default: false
121
+
122
+ def configure(conf)
123
+ super
124
+
125
+ %i[old_cutoff new_cutoff].each do |param|
126
+ # NOTE: Fluentd's :time type drops the sign of values with a unit
127
+ # (e.g. "-1h" is parsed as 3600), so the raw value is checked too.
128
+ raw = conf[param.to_s].to_s.strip
129
+ next unless raw.start_with?('-') || instance_variable_get("@#{param}").negative?
130
+
131
+ raise Fluent::ConfigError, "#{param} must not be negative, got \"#{raw}\""
132
+ end
133
+ %i[source_time_key bad_time_key].each do |param|
134
+ raise Fluent::ConfigError, "#{param} must not be empty" if instance_variable_get("@#{param}").empty?
135
+ end
136
+ if @source_time_key == @bad_time_key
137
+ raise Fluent::ConfigError, "source_time_key and bad_time_key must differ, both are \"#{@source_time_key}\""
138
+ end
139
+
140
+ %i[old_log new_log bad_time_log].each do |param|
141
+ mode = instance_variable_get("@#{param}")
142
+ instance_variable_set("@#{param}", LOG_MODE_ALIASES.fetch(mode, mode))
143
+ end
144
+
145
+ setup_metrics
146
+ end
55
147
 
56
148
  def filter_with_time(tag, time, record)
149
+ ## Safeguard against rogue integer/float/string time. This has to happen
150
+ ## before classification, so that the age check uses the parsed value.
151
+ unless time.is_a?(Fluent::EventTime)
152
+ normalized = normalize_time(time)
153
+ return process_bad_time_record(tag, time, record) if normalized.nil?
154
+
155
+ time = normalized
156
+ end
157
+
57
158
  event_time = time.to_i
58
- current_time = Time.now.to_i
159
+ now = current_time
59
160
 
60
- if event_time < current_time - @old_cutoff
161
+ if event_time < now - @old_cutoff
61
162
  # Too old
62
163
  process_old_record(tag, time, record)
63
- elsif event_time > current_time + @new_cutoff
164
+ elsif event_time > now + @new_cutoff
64
165
  # Too new
65
166
  process_new_record(tag, time, record)
66
167
  else
@@ -71,6 +172,85 @@ module Fluent
71
172
 
72
173
  private
73
174
 
175
+ # Set up the Prometheus counter for caught records, if enabled. The
176
+ # counter is shared by all time_cutoff filters in the process.
177
+ def setup_metrics
178
+ return unless @metrics
179
+
180
+ unless load_prometheus_client
181
+ log.warn 'Metrics are enabled, but the prometheus-client gem is not installed, so caught records will not be counted.'
182
+ return
183
+ end
184
+
185
+ registry = prometheus_registry
186
+ @records_counter = registry.get(METRIC_NAME) || registry.counter(
187
+ METRIC_NAME,
188
+ docstring: 'Number of records caught by the time_cutoff filter.',
189
+ labels: METRIC_LABELS
190
+ )
191
+ @metric_labels = { plugin_id: plugin_id, worker_id: worker_id.to_s, hostname: Socket.gethostname }
192
+ end
193
+
194
+ # Load the prometheus-client gem, which is an optional dependency.
195
+ # @return [Boolean] whether the gem could be loaded
196
+ def load_prometheus_client
197
+ require 'prometheus/client'
198
+ true
199
+ rescue LoadError
200
+ false
201
+ end
202
+
203
+ # The Prometheus registry to register the counter in. This is the one
204
+ # fluent-plugin-prometheus serves on its /metrics endpoints.
205
+ def prometheus_registry
206
+ ::Prometheus::Client.registry
207
+ end
208
+
209
+ # ID of the Fluentd worker this plugin runs in.
210
+ # NOTE: Plugin::Base#fluentd_worker_id isn't available in the oldest
211
+ # Fluentd releases declared in the gemspec, so fall back to the env var
212
+ # it reads.
213
+ # @return [Integer] worker ID
214
+ def worker_id
215
+ return fluentd_worker_id if respond_to?(:fluentd_worker_id, true)
216
+
217
+ (ENV['SERVERENGINE_WORKER_ID'] || 0).to_i
218
+ end
219
+
220
+ # Count a caught record. Errors are logged once and never affect how
221
+ # the record is processed.
222
+ # @param tag [String] log/stream's tag name
223
+ # @param reason [Symbol] why the record was caught (:old, :new or :bad_time)
224
+ # @param action [Symbol] determined action for this record
225
+ def count_record(tag, reason, action)
226
+ return unless @records_counter
227
+
228
+ @records_counter.increment(labels: @metric_labels.merge(tag: tag, reason: reason.to_s, action: action.to_s))
229
+ rescue StandardError => e
230
+ return if @metrics_error_logged
231
+
232
+ @metrics_error_logged = true
233
+ log.warn "Failed to update metrics, further errors will not be logged: #{e.class}: #{e.message}"
234
+ end
235
+
236
+ # Current UNIX time, used as the reference point for the age check.
237
+ # Kept as a separate method so that tests can freeze the clock.
238
+ # @return [Integer] current time in seconds
239
+ def current_time
240
+ Time.now.to_i
241
+ end
242
+
243
+ # Format the event time as an ISO 8601 datetime string (in local time).
244
+ # NOTE: Time.at(int) is used instead of EventTime#to_time, which was
245
+ # only added to Fluentd in v1.8.0, and instead of Time.at(EventTime),
246
+ # which relies on Ruby coercing EventTime via #to_r/#to_int. EventTime#to_i
247
+ # is available in all Fluentd releases declared in the gemspec.
248
+ # @param time [Fluent::EventTime] the event time
249
+ # @return [String] ISO 8601-formatted datetime string
250
+ def iso8601(time)
251
+ Time.at(time.to_i).strftime(TIME_ISO8601)
252
+ end
253
+
74
254
  # Format the event time to a given type/format.
75
255
  # @param time [Fluent::EventTime] the original event time
76
256
  # @param @source_time_format [Symbol] the target format
@@ -82,45 +262,109 @@ module Fluent
82
262
  when :epoch_float
83
263
  time.to_f
84
264
  when :iso8601
85
- # NOTE: Time.at(int) is used instead of EventTime#to_time, which was
86
- # only added to Fluentd in v1.8.0. This keeps us compatible with the
87
- # older Fluentd releases declared in the gemspec.
88
- Time.at(time.to_i).strftime(TIME_ISO8601)
265
+ iso8601(time)
89
266
  end
90
267
  end
91
268
 
92
269
  # Replace event time and put the old time into a specified field.
270
+ # @param tag [String] log/stream's tag name
93
271
  # @param time [Fluent::EventTime] the original event time
94
272
  # @param record [Hash] the original record contents
273
+ # @param log_mode [Symbol] log mode for this record
95
274
  # @param @source_time_key [String] name of the field to put the old timestamp to.
96
275
  # @return [Array] new event time and a modified record.
97
- def do_replace_timestamp(time, record)
98
- new_time = Fluent::EventTime.now
276
+ def do_replace_timestamp(tag, time, record, log_mode)
277
+ [Fluent::EventTime.now, put_time_key(tag, record, @source_time_key, format_time(time), log_mode)]
278
+ end
279
+
280
+ # Put the original time into the given field of a copy of the record,
281
+ # unless the record already has that field and @time_key_conflict says
282
+ # to keep it. The original record is never modified.
283
+ # @param tag [String] log/stream's tag name
284
+ # @param record [Hash] the original record contents
285
+ # @param key [String] name of the field to put the time to
286
+ # @param value the time value to put into the field
287
+ # @param log_mode [Symbol] log mode for this record
288
+ # @return [Hash] the modified copy of the record, or the original record if kept as is
289
+ def put_time_key(tag, record, key, value, log_mode) # rubocop:disable Metrics/ParameterLists
290
+ if record.key?(key) && @time_key_conflict != :overwrite
291
+ log_time_key_conflict(tag, record, key, value, log_mode) if @time_key_conflict == :warn
292
+ return record
293
+ end
294
+
99
295
  new_record = record.dup
100
- old_time = format_time(time)
101
- new_record[@source_time_key] = old_time
296
+ new_record[key] = value
297
+ new_record
298
+ end
102
299
 
103
- [new_time, new_record]
300
+ # Log a time key conflict to Fluentd's log. It is logged even if the
301
+ # record's log mode is :none, since it was asked for explicitly, but the
302
+ # record itself is only included in :full mode.
303
+ # @param tag [String] log/stream's tag name
304
+ # @param record [Hash] the original record contents
305
+ # @param key [String] name of the conflicting field
306
+ # @param value the time value that was not put into the field
307
+ # @param log_mode [Symbol] log mode for this record
308
+ def log_time_key_conflict(tag, record, key, value, log_mode) # rubocop:disable Metrics/ParameterLists
309
+ subject = log_mode == :full ? "#{tag}: #{dump_record(record)}" : tag
310
+ log.warn format(
311
+ 'Record already has the "%<key>s" key, keeping its existing value instead of %<val>s: "%<subject>s".',
312
+ key: key, val: value.inspect, subject: subject
313
+ )
104
314
  end
105
315
 
106
316
  # Log the caught event to Fluentd's log.
107
317
  # @param tag [String] log/stream's tag name
108
- # @param time [Fluent::EventTime] the original event time
318
+ # @param time [Fluent::EventTime] the original event time, or the raw
319
+ # time for :bad_time records
109
320
  # @param record [Hash] the original record contents
110
321
  # @param action [Symbol] determined action for this record
111
- # @param age [Symbol] detemined age for this record (:old or :new)
112
- def log_record(tag, time, record, action, age)
113
- fmt_time = Time.at(time).strftime(TIME_ISO8601)
114
- log_line = format(
115
- 'Record caught [%<age>s, %<act>s]: "%<tag>s: %<time>s %<msg>s".',
116
- age: age, act: action, tag: tag, time: fmt_time, msg: record.to_json
322
+ # @param reason [Symbol] why the record was caught (:old, :new or :bad_time)
323
+ # @param log_mode [Symbol] :none, :metadata (no record contents) or :full
324
+ def log_record(tag, time, record, action, reason, log_mode) # rubocop:disable Metrics/ParameterLists
325
+ return if log_mode == :none
326
+
327
+ details = reason == :bad_time ? time.inspect : iso8601(time)
328
+ if log_mode == :full
329
+ details = "#{details} #{dump_record(record)}"
330
+ elsif reason != :bad_time
331
+ details = "#{details} (age #{format_age(current_time - time.to_i)})"
332
+ end
333
+ log.warn format(
334
+ 'Record caught [%<reason>s, %<act>s]: "%<tag>s: %<details>s".',
335
+ reason: reason, act: action, tag: tag, details: details
117
336
  )
118
- log.warn log_line
119
337
  end
120
338
 
121
- # Normalize time to Fluent::EventTime if it's plain int or float.
122
- # @param time [Integer|Float] incoming event time
123
- # @return [Fluent::EventTime] event time normalized to Fluentd's internal format
339
+ # Format an age in seconds as a compact human-readable string, e.g.
340
+ # "1d 2h 3m 4s", "5m 0s" or "-2h 0m 5s" for times in the future.
341
+ # @param seconds [Integer] the age in seconds
342
+ # @return [String] the formatted age
343
+ def format_age(seconds)
344
+ rest = seconds.abs
345
+ parts = [[86_400, 'd'], [3600, 'h'], [60, 'm'], [1, 's']].map do |size, unit|
346
+ value, rest = rest.divmod(size)
347
+ "#{value}#{unit}"
348
+ end
349
+ parts.shift while parts.size > 1 && parts.first.start_with?('0')
350
+ "#{'-' if seconds.negative?}#{parts.join(' ')}"
351
+ end
352
+
353
+ # Serialize the record for logging. Falls back to #inspect when the
354
+ # record can't be represented as JSON (e.g. invalid UTF-8 or NaN), so
355
+ # that a logging failure never affects how the record is processed.
356
+ # @param record [Hash] the original record contents
357
+ # @return [String] the record as a JSON string, or its #inspect output
358
+ def dump_record(record)
359
+ record.to_json
360
+ rescue JSON::GeneratorError, EncodingError
361
+ record.inspect
362
+ end
363
+
364
+ # Normalize time to Fluent::EventTime if it's plain int or float, a
365
+ # numeric string or an ISO 8601 datetime string.
366
+ # @param time [Integer|Float|String] incoming event time
367
+ # @return [Fluent::EventTime,nil] event time normalized to Fluentd's internal format, or nil if unparseable
124
368
  def normalize_time(time)
125
369
  case time
126
370
  when Integer
@@ -129,50 +373,94 @@ module Fluent
129
373
  time_int, time_frac = time.divmod(1)
130
374
  Fluent::EventTime.new(time_int, (time_frac * 10**9).to_i)
131
375
  else
132
- log.warn "Unknown time format given (#{time.class}): \"#{time.inspect}\"."
133
- numeric = coerce_to_number(time)
134
- numeric ? normalize_time(numeric) : Fluent::EventTime.now
376
+ if (numeric = coerce_to_number(time))
377
+ normalize_time(numeric)
378
+ elsif (parsed = parse_iso8601(time))
379
+ Fluent::EventTime.new(parsed.to_i, parsed.nsec)
380
+ end
135
381
  end
136
382
  end
137
383
 
138
384
  # Try to coerce an arbitrary value into a number, or return nil.
139
- # Uses begin/rescue rather than the Integer()/Float() "exception:"
140
- # keyword (Ruby 2.6+) so the plugin keeps working on older Rubies.
385
+ # Integer() is called with an explicit base 10 so that zero-padded
386
+ # strings (e.g. "01700000000") aren't parsed as octal.
141
387
  # @param value the value to coerce
142
388
  # @return [Integer,Float,nil] the parsed number, or nil if not numeric
143
389
  def coerce_to_number(value)
144
- Integer(value)
145
- rescue ArgumentError, TypeError
146
- begin
147
- Float(value)
148
- rescue ArgumentError, TypeError
149
- nil
390
+ Integer(value, 10, exception: false) || Float(value, exception: false)
391
+ end
392
+
393
+ # Try to parse a strict ISO 8601 datetime string, or return nil.
394
+ # Time.iso8601 is used instead of the lenient Time.parse, which would
395
+ # happily turn partial or ambiguous strings into a wrong timestamp.
396
+ # "Relaxed" strings (see ISO8601_RELAXED) are rebuilt as strict ones
397
+ # before parsing. Strings without a UTC offset are treated as local time.
398
+ # @param value the value to parse
399
+ # @return [Time,nil] the parsed time, or nil if not an ISO 8601 string
400
+ def parse_iso8601(value)
401
+ return nil unless value.is_a?(String)
402
+
403
+ if (match = ISO8601_RELAXED.match(value))
404
+ zone = match[:zone] == 'UTC' ? 'Z' : match[:zone]
405
+ value = "#{match[:date]}T#{match[:time]}#{zone}"
150
406
  end
407
+ Time.iso8601(value)
408
+ rescue ArgumentError
409
+ nil
151
410
  end
152
411
 
153
412
  # Process the record according to its determined "age"
154
413
  # @param tag [String] log/stream's tag name
155
414
  # @param time [Fluent::EventTime] the original event time
156
415
  # @param record [Hash] the original record contents
157
- # @param do_log [Boolean] should we log this record or not
416
+ # @param log_mode [Symbol] log mode for this record
158
417
  # @param action [Symbol] determined action for this record
159
- # @param age [Symbol] detemined age for this record (:old or :new)
160
- def process_record(tag, time, record, do_log, action, age) # rubocop:disable Metrics/ParameterLists
161
- ## Safeguard against rogue integer time.
162
- time = normalize_time(time) unless time.is_a?(Fluent::EventTime)
163
-
164
- log_record(tag, time, record, action, age) if do_log
418
+ # @param reason [Symbol] why the record was caught (:old or :new)
419
+ def process_record(tag, time, record, log_mode, action, reason) # rubocop:disable Metrics/ParameterLists
420
+ count_record(tag, reason, action)
421
+ log_record(tag, time, record, action, reason, log_mode)
165
422
 
166
423
  case action
167
424
  when :pass
168
425
  [time, record]
169
426
  when :replace_timestamp
170
- do_replace_timestamp(time, record)
427
+ do_replace_timestamp(tag, time, record, log_mode)
428
+ when :drop
429
+ nil
430
+ end
431
+ end
432
+
433
+ # Process the record with an unparseable time according to the configuration
434
+ # @param tag [String] log/stream's tag name
435
+ # @param time the original (unparseable) event time
436
+ # @param record [Hash] the original record contents
437
+ # @return [Array,nil] current time and the record, or nil if dropped.
438
+ def process_bad_time_record(tag, time, record)
439
+ count_record(tag, :bad_time, @bad_time_action)
440
+ log_record(tag, time, record, @bad_time_action, :bad_time, @bad_time_log)
441
+
442
+ case @bad_time_action
443
+ when :replace_timestamp
444
+ do_replace_bad_timestamp(tag, time, record)
171
445
  when :drop
172
446
  nil
173
447
  end
174
448
  end
175
449
 
450
+ # Replace unparseable event time and put the raw original time into a
451
+ # separate field. The raw time is always stored as a string, so that the
452
+ # field keeps a stable type for downstream storage. It is not put into
453
+ # @source_time_key, which holds a value in @source_time_format instead.
454
+ # @param tag [String] log/stream's tag name
455
+ # @param time the original (unparseable) event time
456
+ # @param record [Hash] the original record contents
457
+ # @param @bad_time_key [String] name of the field to put the raw time to.
458
+ # @return [Array] new event time and a modified record.
459
+ def do_replace_bad_timestamp(tag, time, record)
460
+ raw_time = time.is_a?(String) ? time : time.inspect
461
+ [Fluent::EventTime.now, put_time_key(tag, record, @bad_time_key, raw_time, @bad_time_log)]
462
+ end
463
+
176
464
  # Process the "old" record according to the configuration
177
465
  # @param tag [String] log/stream's tag name
178
466
  # @param time [Fluent::EventTime] the original event time
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluent-plugin-time-cutoff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Qrator Labs
@@ -31,33 +31,39 @@ dependencies:
31
31
  - !ruby/object:Gem::Version
32
32
  version: '2'
33
33
  - !ruby/object:Gem::Dependency
34
- name: bundler
34
+ name: prometheus-client
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.1'
40
+ - - "<"
38
41
  - !ruby/object:Gem::Version
39
- version: 2.6.3
42
+ version: '6'
40
43
  type: :development
41
44
  prerelease: false
42
45
  version_requirements: !ruby/object:Gem::Requirement
43
46
  requirements:
44
- - - "~>"
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2.1'
50
+ - - "<"
45
51
  - !ruby/object:Gem::Version
46
- version: 2.6.3
52
+ version: '6'
47
53
  - !ruby/object:Gem::Dependency
48
54
  name: rake
49
55
  requirement: !ruby/object:Gem::Requirement
50
56
  requirements:
51
57
  - - "~>"
52
58
  - !ruby/object:Gem::Version
53
- version: 13.1.0
59
+ version: 13.2.0
54
60
  type: :development
55
61
  prerelease: false
56
62
  version_requirements: !ruby/object:Gem::Requirement
57
63
  requirements:
58
64
  - - "~>"
59
65
  - !ruby/object:Gem::Version
60
- version: 13.1.0
66
+ version: 13.2.0
61
67
  - !ruby/object:Gem::Dependency
62
68
  name: test-unit
63
69
  requirement: !ruby/object:Gem::Requirement
@@ -81,11 +87,8 @@ executables: []
81
87
  extensions: []
82
88
  extra_rdoc_files: []
83
89
  files:
84
- - ".rubocop.yml"
85
90
  - LICENSE.txt
86
91
  - README.md
87
- - Rakefile
88
- - fluent-plugin-time-cutoff.gemspec
89
92
  - lib/fluent/plugin/filter_time_cutoff.rb
90
93
  homepage: https://github.com/QratorLabs/fluent-plugin-time-cutoff
91
94
  licenses:
@@ -98,14 +101,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
98
101
  requirements:
99
102
  - - ">="
100
103
  - !ruby/object:Gem::Version
101
- version: '2.3'
104
+ version: '2.7'
102
105
  required_rubygems_version: !ruby/object:Gem::Requirement
103
106
  requirements:
104
107
  - - ">="
105
108
  - !ruby/object:Gem::Version
106
109
  version: '0'
107
110
  requirements: []
108
- rubygems_version: 4.0.9
111
+ rubygems_version: 4.0.18
109
112
  specification_version: 4
110
113
  summary: Fluentd time-based filter plugin.
111
114
  test_files: []
data/.rubocop.yml DELETED
@@ -1,2 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 2.3
data/Rakefile DELETED
@@ -1,13 +0,0 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
3
-
4
- require 'rake/testtask'
5
-
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs.push('lib', 'test')
8
- t.test_files = FileList['test/**/test_*.rb']
9
- t.verbose = true
10
- t.warning = true
11
- end
12
-
13
- task default: [:test]
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- lib = File.expand_path('../lib', __dir__)
4
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
-
6
- Gem::Specification.new do |spec|
7
- spec.name = 'fluent-plugin-time-cutoff'
8
- spec.version = '0.2.0'
9
- spec.authors = ['Qrator Labs', 'Serge Tkatchouk']
10
- spec.email = ['devops@qrator.net', 'st@qrator.net']
11
-
12
- spec.summary = 'Fluentd time-based filter plugin.'
13
- spec.description = 'A plugin that lets Fluentd to prune/rewrite messages '\
14
- 'that have a timestamp that is too old or too new.'
15
- spec.homepage = 'https://github.com/QratorLabs/fluent-plugin-time-cutoff'
16
- spec.license = 'MIT'
17
-
18
- spec.files = Dir.chdir(__dir__) do
19
- `git ls-files -z`.split("\x0").reject do |f|
20
- (File.expand_path(f) == __FILE__) ||
21
- f.start_with?(*%w[test/ testbed/ spec/ features/ .git .circleci appveyor Gemfile])
22
- end
23
- end
24
-
25
- spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
26
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
27
- spec.require_paths = ['lib']
28
-
29
- spec.platform = Gem::Platform::RUBY
30
- spec.required_ruby_version = Gem::Requirement.new('>= 2.3')
31
-
32
- spec.add_runtime_dependency 'fluentd', ['>= 0.14.10', '< 2']
33
-
34
- spec.add_development_dependency 'bundler', '~> 2.6.3'
35
- spec.add_development_dependency 'rake', '~> 13.1.0'
36
- spec.add_development_dependency 'test-unit', '~> 3.6.1'
37
- end