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 +4 -4
- data/README.md +140 -9
- data/lib/fluent/plugin/filter_time_cutoff.rb +334 -46
- metadata +16 -13
- data/.rubocop.yml +0 -2
- data/Rakefile +0 -13
- data/fluent-plugin-time-cutoff.gemspec +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a52993553c1cdc40bb6800791120e5248662a1f84546bf13b686c90c16f5c35d
|
|
4
|
+
data.tar.gz: c58051186b9056ee4fe0b6aa25850e11d1013de01038cca983323e525e551bdd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
107
|
+
old_log full
|
|
108
108
|
|
|
109
109
|
new_cutoff 24h
|
|
110
110
|
new_action pass
|
|
111
|
-
new_log
|
|
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 (
|
|
143
|
+
### old_log (enum) (optional)
|
|
134
144
|
|
|
135
|
-
Log the "old" messages to Fluentd's own log. Defaults to "
|
|
145
|
+
Log the "old" messages to Fluentd's own log. Defaults to "full".
|
|
136
146
|
|
|
137
|
-
|
|
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 (
|
|
169
|
+
### new_log (enum) (optional)
|
|
170
|
+
|
|
171
|
+
Log the "new" messages to Fluentd's own log. Defaults to "full".
|
|
154
172
|
|
|
155
|
-
|
|
173
|
+
Available values: none (or false/no), metadata, full (or true/yes)
|
|
156
174
|
|
|
157
|
-
|
|
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 "
|
|
44
|
-
config_param :old_log, :
|
|
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 "
|
|
50
|
-
config_param :new_log, :
|
|
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
|
-
|
|
159
|
+
now = current_time
|
|
59
160
|
|
|
60
|
-
if event_time <
|
|
161
|
+
if event_time < now - @old_cutoff
|
|
61
162
|
# Too old
|
|
62
163
|
process_old_record(tag, time, record)
|
|
63
|
-
elsif event_time >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
new_record
|
|
296
|
+
new_record[key] = value
|
|
297
|
+
new_record
|
|
298
|
+
end
|
|
102
299
|
|
|
103
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
#
|
|
122
|
-
#
|
|
123
|
-
# @
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
#
|
|
140
|
-
#
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
416
|
+
# @param log_mode [Symbol] log mode for this record
|
|
158
417
|
# @param action [Symbol] determined action for this record
|
|
159
|
-
# @param
|
|
160
|
-
def process_record(tag, time, record,
|
|
161
|
-
|
|
162
|
-
time
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
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
|