fluentd-json-size-limit 0.1.4 → 0.2.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/CHANGELOG.md +24 -0
- data/LICENSE +16 -202
- data/NOTICE.md +3 -0
- data/README.md +61 -26
- data/fluentd-json-size-limit.gemspec +37 -20
- data/lib/fluent/plugin/filter_jsonsizelimit.rb +4 -67
- data/lib/fluent/plugin/json_size_limit/errors.rb +53 -0
- data/lib/fluent/plugin/json_size_limit/filter.rb +154 -0
- data/lib/fluent/plugin/json_size_limit/filter_configuration.rb +48 -0
- data/lib/fluent/plugin/json_size_limit/json_string.rb +169 -0
- data/lib/fluent/plugin/json_size_limit/processing_guard.rb +77 -0
- data/lib/fluent/plugin/json_size_limit/rate_limited_logger.rb +76 -0
- data/lib/fluent/plugin/json_size_limit/record_measurer.rb +133 -0
- data/lib/fluent/plugin/json_size_limit/record_processor.rb +52 -0
- data/lib/fluent/plugin/json_size_limit/record_reducer.rb +258 -0
- data/lib/fluent/plugin/json_size_limit/string_encoding.rb +25 -0
- data/lib/fluent/plugin/json_size_limit/telemetry.rb +60 -0
- data/lib/fluent/plugin/json_size_limit/version.rb +9 -0
- data/lib/fluent/plugin/json_size_limit.rb +15 -0
- metadata +143 -38
- data/.gitignore +0 -2
- data/.gitlab-ci.yml +0 -171
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -51
- data/Rakefile +0 -13
- data/test/helper.rb +0 -7
- data/test/plugin/test_fluentd-json-size-limit.rb +0 -43
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fluent/plugin/filter"
|
|
4
|
+
require "fluent/plugin/json_size_limit"
|
|
5
|
+
|
|
6
|
+
module Fluent
|
|
7
|
+
module Plugin
|
|
8
|
+
class JsonSizeLimitFilter < Filter
|
|
9
|
+
include JsonSizeLimit::FilterConfiguration
|
|
10
|
+
|
|
11
|
+
Fluent::Plugin.register_filter("jsonsizelimit", self)
|
|
12
|
+
|
|
13
|
+
# Compatibility constant for callers that referenced the earlier class.
|
|
14
|
+
UnreducibleRecordError = JsonSizeLimit::UnreducibleRecordError
|
|
15
|
+
|
|
16
|
+
def configure(conf)
|
|
17
|
+
super
|
|
18
|
+
validate_json_size_limit_configuration!
|
|
19
|
+
|
|
20
|
+
@telemetry = JsonSizeLimit::Telemetry.new { |name, help_text| create_metric(name, help_text) }
|
|
21
|
+
@record_processor = JsonSizeLimit::RecordProcessor.new(
|
|
22
|
+
max_json_bytes: @max_size,
|
|
23
|
+
safety_options: processing_safety_options
|
|
24
|
+
)
|
|
25
|
+
@operational_log = JsonSizeLimit::RateLimitedLogger.new(log, @warning_interval)
|
|
26
|
+
|
|
27
|
+
log.on_debug do
|
|
28
|
+
log.debug(
|
|
29
|
+
"Configured JsonSizeLimitFilter",
|
|
30
|
+
max_size: @max_size,
|
|
31
|
+
enabled: @enabled,
|
|
32
|
+
overflow_action: @overflow_action,
|
|
33
|
+
error_action: @error_action,
|
|
34
|
+
processing_timeout: @processing_timeout,
|
|
35
|
+
max_record_nodes: @max_record_nodes,
|
|
36
|
+
max_input_size: @max_input_size,
|
|
37
|
+
max_nesting: @max_nesting
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The processor has no mutable cross-record state. Fluentd automatically
|
|
43
|
+
# invokes the same implementation from every configured worker/thread.
|
|
44
|
+
def multi_workers_ready?
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def filter(_tag, _time, record)
|
|
49
|
+
return record unless @enabled
|
|
50
|
+
|
|
51
|
+
result = @record_processor.call(record)
|
|
52
|
+
|
|
53
|
+
case result.status
|
|
54
|
+
when :within_limit
|
|
55
|
+
record
|
|
56
|
+
when :reduced
|
|
57
|
+
handle_reduced(record, result)
|
|
58
|
+
when :overflow
|
|
59
|
+
handle_overflow(record, result)
|
|
60
|
+
else
|
|
61
|
+
raise JsonSizeLimit::UnexpectedProcessorStatusError, result.status
|
|
62
|
+
end
|
|
63
|
+
rescue UnreducibleRecordError
|
|
64
|
+
raise
|
|
65
|
+
rescue => error
|
|
66
|
+
handle_error(record, error)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def statistics
|
|
70
|
+
super.merge("json_size_limit" => @telemetry&.statistics || {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shutdown
|
|
74
|
+
@operational_log&.flush
|
|
75
|
+
ensure
|
|
76
|
+
super
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def create_metric(name, help_text)
|
|
82
|
+
metrics_create(
|
|
83
|
+
namespace: "fluentd",
|
|
84
|
+
subsystem: "json_size_limit",
|
|
85
|
+
name: name,
|
|
86
|
+
help_text: help_text
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_reduced(record, result)
|
|
91
|
+
# RecordProcessor applies replacements in place so Fluentd retains the
|
|
92
|
+
# original record identity and any aliases owned by upstream plugins.
|
|
93
|
+
@telemetry.increment(:oversized_records)
|
|
94
|
+
@telemetry.increment(:reduced_records)
|
|
95
|
+
@telemetry.increment(:removed_bytes, result.original_json_bytes - result.json_bytes)
|
|
96
|
+
log.on_debug do
|
|
97
|
+
log.debug(
|
|
98
|
+
"Reduced oversized JSON record",
|
|
99
|
+
original_json_bytes: result.original_json_bytes,
|
|
100
|
+
reduced_json_bytes: result.json_bytes,
|
|
101
|
+
max_json_bytes: @max_size
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
record
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def handle_overflow(record, result)
|
|
108
|
+
@telemetry.increment(:oversized_records)
|
|
109
|
+
@telemetry.increment(:unreducible_records)
|
|
110
|
+
fields = {
|
|
111
|
+
action: @overflow_action,
|
|
112
|
+
original_json_bytes: result.original_json_bytes,
|
|
113
|
+
reduced_json_bytes: result.json_bytes,
|
|
114
|
+
max_json_bytes: @max_size
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case @overflow_action
|
|
118
|
+
when :pass
|
|
119
|
+
@operational_log.emit(:warn, :overflow, "Record remains above max_size after string reduction", fields)
|
|
120
|
+
record
|
|
121
|
+
when :drop
|
|
122
|
+
@telemetry.increment(:dropped_records)
|
|
123
|
+
@operational_log.emit(:warn, :overflow, "Dropping record that remains above max_size", fields)
|
|
124
|
+
nil
|
|
125
|
+
when :raise
|
|
126
|
+
raise UnreducibleRecordError,
|
|
127
|
+
"Record remains above max_size after string reduction: " \
|
|
128
|
+
"original=#{result.original_json_bytes} bytes, reduced=#{result.json_bytes} bytes, max=#{@max_size} bytes"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def handle_error(record, error)
|
|
133
|
+
@telemetry.record_error(error)
|
|
134
|
+
fields = {
|
|
135
|
+
action: @error_action,
|
|
136
|
+
error_class: error.class.name
|
|
137
|
+
}
|
|
138
|
+
fields[:safety_limit] = error.limit if error.is_a?(JsonSizeLimit::ProcessingLimitError)
|
|
139
|
+
|
|
140
|
+
case @error_action
|
|
141
|
+
when :pass
|
|
142
|
+
@operational_log.emit(:error, :error, "Failed to measure or reduce record JSON", fields)
|
|
143
|
+
record
|
|
144
|
+
when :drop
|
|
145
|
+
@telemetry.increment(:dropped_records)
|
|
146
|
+
@operational_log.emit(:error, :error, "Dropping record after JSON measurement or reduction failure", fields)
|
|
147
|
+
nil
|
|
148
|
+
when :raise
|
|
149
|
+
raise error
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fluent
|
|
4
|
+
module Plugin
|
|
5
|
+
module JsonSizeLimit
|
|
6
|
+
module FilterConfiguration
|
|
7
|
+
DEFAULT_MAX_INPUT_SIZE = 64 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.class_eval do
|
|
11
|
+
config_param :max_size, :size, default: 250 * 1024
|
|
12
|
+
config_param :enabled, :bool, default: true
|
|
13
|
+
config_param :overflow_action, :enum, list: %i[pass drop raise], default: :pass
|
|
14
|
+
config_param :error_action, :enum, list: %i[pass drop raise], default: :pass
|
|
15
|
+
config_param :warning_interval, :time, default: 60
|
|
16
|
+
|
|
17
|
+
# Automatic safety budgets. Existing configurations need no new
|
|
18
|
+
# parameters; operators may raise them for exceptional workloads.
|
|
19
|
+
config_param :processing_timeout, :time, default: 5
|
|
20
|
+
config_param :max_record_nodes, :integer, default: 1_000_000
|
|
21
|
+
config_param :max_input_size, :size, default: DEFAULT_MAX_INPUT_SIZE
|
|
22
|
+
config_param :max_nesting, :integer, default: 100
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_json_size_limit_configuration!
|
|
29
|
+
raise Fluent::ConfigError, "max_size must be greater than zero" unless @max_size.positive?
|
|
30
|
+
raise Fluent::ConfigError, "warning_interval must not be negative" if @warning_interval.negative?
|
|
31
|
+
raise Fluent::ConfigError, "processing_timeout must be greater than zero" unless @processing_timeout.positive?
|
|
32
|
+
raise Fluent::ConfigError, "max_record_nodes must be greater than zero" unless @max_record_nodes.positive?
|
|
33
|
+
raise Fluent::ConfigError, "max_input_size must be greater than zero" unless @max_input_size.positive?
|
|
34
|
+
raise Fluent::ConfigError, "max_nesting must be greater than zero" unless @max_nesting.positive?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def processing_safety_options
|
|
38
|
+
{
|
|
39
|
+
timeout: @processing_timeout,
|
|
40
|
+
max_nodes: @max_record_nodes,
|
|
41
|
+
max_input_bytes: [@max_input_size, @max_size].max,
|
|
42
|
+
max_nesting: @max_nesting
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fluent/plugin/json_size_limit/string_encoding"
|
|
5
|
+
|
|
6
|
+
module Fluent
|
|
7
|
+
module Plugin
|
|
8
|
+
module JsonSizeLimit
|
|
9
|
+
# Computes and truncates the bytes between a JSON string's quotes. Plain
|
|
10
|
+
# UTF-8 takes a zero-serialization path; escaped UTF-8 is generated once
|
|
11
|
+
# and decoded at a safe JSON token boundary.
|
|
12
|
+
class JsonString
|
|
13
|
+
ESCAPED_CHARACTERS = "\"\\\\\x00-\x1f"
|
|
14
|
+
ESCAPE_REQUIRED = /["\\\x00-\x1f]/
|
|
15
|
+
COUNT_SCAN_THRESHOLD = 1024
|
|
16
|
+
UTF_8 = Encoding::UTF_8
|
|
17
|
+
|
|
18
|
+
attr_reader :content_bytes, :truncated_content_bytes, :value
|
|
19
|
+
|
|
20
|
+
def self.content_bytes(value)
|
|
21
|
+
new(value).content_bytes
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.truncate(value, allowed_content_bytes)
|
|
25
|
+
json_string = new(value)
|
|
26
|
+
truncated_value = json_string.truncate(allowed_content_bytes)
|
|
27
|
+
[truncated_value, json_string.truncated_content_bytes]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(value, validate_encoding: true)
|
|
31
|
+
@value = value
|
|
32
|
+
StringEncoding.validate!(value) if validate_encoding
|
|
33
|
+
|
|
34
|
+
if raw_json_content?
|
|
35
|
+
@strategy = :raw
|
|
36
|
+
@content_bytes = value.bytesize
|
|
37
|
+
else
|
|
38
|
+
@generated = JSON.generate(value)
|
|
39
|
+
@content_bytes = @generated.bytesize - 2
|
|
40
|
+
@strategy = directly_decodable? ? :generated : :fallback
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def truncate(allowed_content_bytes)
|
|
45
|
+
if allowed_content_bytes <= 0
|
|
46
|
+
@truncated_content_bytes = 0
|
|
47
|
+
return empty_value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if allowed_content_bytes >= @content_bytes
|
|
51
|
+
@truncated_content_bytes = @content_bytes
|
|
52
|
+
return @value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
case @strategy
|
|
56
|
+
when :raw
|
|
57
|
+
truncate_raw(allowed_content_bytes)
|
|
58
|
+
when :generated
|
|
59
|
+
truncate_generated(allowed_content_bytes)
|
|
60
|
+
when :fallback
|
|
61
|
+
truncate_with_binary_search(allowed_content_bytes)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def directly_decodable?
|
|
68
|
+
return false unless @value.instance_of?(String)
|
|
69
|
+
|
|
70
|
+
@value.ascii_only? || (@value.encoding == UTF_8 && @value.valid_encoding?)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def raw_json_content?
|
|
74
|
+
directly_decodable? && !escape_required?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def escape_required?
|
|
78
|
+
if @value.bytesize < COUNT_SCAN_THRESHOLD
|
|
79
|
+
ESCAPE_REQUIRED.match?(@value)
|
|
80
|
+
else
|
|
81
|
+
!@value.count(ESCAPED_CHARACTERS).zero?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def truncate_raw(allowed_content_bytes)
|
|
86
|
+
byte_limit = allowed_content_bytes
|
|
87
|
+
|
|
88
|
+
if @value.encoding == UTF_8
|
|
89
|
+
byte_limit -= 1 while byte_limit.positive? && continuation_byte?(@value.getbyte(byte_limit))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
prefix = @value.byteslice(0, byte_limit)
|
|
93
|
+
@truncated_content_bytes = prefix.bytesize
|
|
94
|
+
prefix
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A cut can only split a UTF-8 sequence (at most 4 bytes) or a JSON
|
|
98
|
+
# escape (at most 6 bytes). Retrying from the desired byte budget finds
|
|
99
|
+
# the maximal valid prefix without serializing source prefixes again.
|
|
100
|
+
def truncate_generated(allowed_content_bytes)
|
|
101
|
+
content_bytes = allowed_content_bytes
|
|
102
|
+
|
|
103
|
+
loop do
|
|
104
|
+
candidate = @generated.byteslice(0, content_bytes + 1)
|
|
105
|
+
candidate << '"'
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
prefix = JSON.parse(candidate)
|
|
109
|
+
unless prefix.valid_encoding?
|
|
110
|
+
content_bytes -= 1
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
prefix.force_encoding(@value.encoding) if @value.ascii_only? && @value.encoding != UTF_8
|
|
115
|
+
@truncated_content_bytes = content_bytes
|
|
116
|
+
return prefix
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
content_bytes -= 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def truncate_with_binary_search(allowed_content_bytes)
|
|
124
|
+
low = 0
|
|
125
|
+
high = @value.bytesize
|
|
126
|
+
best_prefix = empty_value
|
|
127
|
+
best_json_bytes = 0
|
|
128
|
+
|
|
129
|
+
while low <= high
|
|
130
|
+
midpoint = (low + high) / 2
|
|
131
|
+
prefix = valid_encoding_prefix(midpoint)
|
|
132
|
+
prefix_json_bytes = JSON.generate(prefix).bytesize - 2
|
|
133
|
+
|
|
134
|
+
if prefix_json_bytes <= allowed_content_bytes
|
|
135
|
+
best_prefix = prefix
|
|
136
|
+
best_json_bytes = prefix_json_bytes
|
|
137
|
+
low = midpoint + 1
|
|
138
|
+
else
|
|
139
|
+
high = midpoint - 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
@truncated_content_bytes = best_json_bytes
|
|
144
|
+
best_prefix
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def valid_encoding_prefix(byte_limit)
|
|
148
|
+
return @value if byte_limit >= @value.bytesize
|
|
149
|
+
|
|
150
|
+
prefix = @value.byteslice(0, byte_limit)
|
|
151
|
+
while byte_limit.positive? && !prefix.valid_encoding?
|
|
152
|
+
byte_limit -= 1
|
|
153
|
+
prefix = @value.byteslice(0, byte_limit)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
prefix || empty_value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def continuation_byte?(byte)
|
|
160
|
+
byte && byte >= 0x80 && byte < 0xC0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def empty_value
|
|
164
|
+
@value.byteslice(0, 0) || String.new(encoding: @value.encoding)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fluent/plugin/json_size_limit/errors"
|
|
4
|
+
|
|
5
|
+
module Fluent
|
|
6
|
+
module Plugin
|
|
7
|
+
module JsonSizeLimit
|
|
8
|
+
# Cooperative per-record limits. Clock checks are sampled to keep the hot
|
|
9
|
+
# path cheap, while explicit checkpoints surround potentially large work.
|
|
10
|
+
class ProcessingGuard
|
|
11
|
+
MONOTONIC_TIME = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
12
|
+
CHECK_INTERVAL = 256
|
|
13
|
+
|
|
14
|
+
def initialize(timeout:, max_nodes:, max_input_bytes:, max_nesting:, clock: MONOTONIC_TIME,
|
|
15
|
+
check_interval: CHECK_INTERVAL)
|
|
16
|
+
validate_options!(timeout, max_nodes, max_input_bytes, max_nesting, clock, check_interval)
|
|
17
|
+
|
|
18
|
+
@clock = clock
|
|
19
|
+
@deadline = clock.call + timeout
|
|
20
|
+
@max_nodes = max_nodes
|
|
21
|
+
@max_input_bytes = max_input_bytes
|
|
22
|
+
@max_nesting = max_nesting
|
|
23
|
+
@check_interval = check_interval
|
|
24
|
+
reset_phase!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit!(depth, input_bytes = 0)
|
|
28
|
+
raise ProcessingLimitError, :max_nesting if depth > @max_nesting
|
|
29
|
+
|
|
30
|
+
@nodes += 1
|
|
31
|
+
raise ProcessingLimitError, :max_record_nodes if @nodes > @max_nodes
|
|
32
|
+
|
|
33
|
+
account_input_bytes!(input_bytes)
|
|
34
|
+
checkpoint! if (@nodes % @check_interval).zero?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def account_input_bytes!(bytes)
|
|
38
|
+
return unless @track_input
|
|
39
|
+
|
|
40
|
+
@input_bytes += bytes
|
|
41
|
+
raise ProcessingLimitError, :max_input_size if @input_bytes > @max_input_bytes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enforce_input_bytes!(bytes)
|
|
45
|
+
raise ProcessingLimitError, :max_input_size if bytes > @max_input_bytes
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def checkpoint!
|
|
49
|
+
raise ProcessingLimitError, :processing_timeout if @clock.call > @deadline
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset_phase!(track_input: true)
|
|
53
|
+
@nodes = 0
|
|
54
|
+
@input_bytes = 0
|
|
55
|
+
@track_input = track_input
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_options!(timeout, max_nodes, max_input_bytes, max_nesting, clock, check_interval)
|
|
61
|
+
raise ArgumentError, "timeout must be a positive number" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
62
|
+
raise ArgumentError, "max_nodes must be a positive integer" unless max_nodes.is_a?(Integer) && max_nodes.positive?
|
|
63
|
+
unless max_input_bytes.is_a?(Numeric) && max_input_bytes.positive?
|
|
64
|
+
raise ArgumentError, "max_input_bytes must be a positive number"
|
|
65
|
+
end
|
|
66
|
+
unless max_nesting.is_a?(Integer) && max_nesting.positive?
|
|
67
|
+
raise ArgumentError, "max_nesting must be a positive integer"
|
|
68
|
+
end
|
|
69
|
+
unless check_interval.is_a?(Integer) && check_interval.positive?
|
|
70
|
+
raise ArgumentError, "check_interval must be a positive integer"
|
|
71
|
+
end
|
|
72
|
+
raise ArgumentError, "clock must respond to call" unless clock.respond_to?(:call)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fluent
|
|
4
|
+
module Plugin
|
|
5
|
+
module JsonSizeLimit
|
|
6
|
+
# Serializes rate-limit state across Fluentd threads without putting a
|
|
7
|
+
# lock on the normal record-processing path.
|
|
8
|
+
class RateLimitedLogger
|
|
9
|
+
MONOTONIC_TIME = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
10
|
+
|
|
11
|
+
def initialize(logger, interval, clock: MONOTONIC_TIME)
|
|
12
|
+
raise ArgumentError, "interval must not be negative" unless interval.is_a?(Numeric) && !interval.negative?
|
|
13
|
+
raise ArgumentError, "clock must respond to call" unless clock.respond_to?(:call)
|
|
14
|
+
|
|
15
|
+
@logger = logger
|
|
16
|
+
@interval = interval
|
|
17
|
+
@clock = clock
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@states = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit(level, category, message, fields = {})
|
|
23
|
+
return write(level, message, fields) if @interval.zero?
|
|
24
|
+
|
|
25
|
+
now = @clock.call
|
|
26
|
+
suppressed = @mutex.synchronize do
|
|
27
|
+
state = (@states[category] ||= {last_at: nil, suppressed: 0})
|
|
28
|
+
|
|
29
|
+
if state[:last_at].nil? || now - state[:last_at] >= @interval
|
|
30
|
+
suppressed_count = state[:suppressed]
|
|
31
|
+
state[:last_at] = now
|
|
32
|
+
state[:suppressed] = 0
|
|
33
|
+
suppressed_count
|
|
34
|
+
else
|
|
35
|
+
state[:suppressed] += 1
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
return unless suppressed
|
|
40
|
+
|
|
41
|
+
fields = fields.merge(suppressed_events: suppressed) if suppressed.positive?
|
|
42
|
+
write(level, message, fields)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def flush
|
|
46
|
+
summaries = @mutex.synchronize do
|
|
47
|
+
@states.filter_map do |category, state|
|
|
48
|
+
next unless state[:suppressed].positive?
|
|
49
|
+
|
|
50
|
+
summary = [category, state[:suppressed]]
|
|
51
|
+
state[:suppressed] = 0
|
|
52
|
+
summary
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
summaries.each do |category, suppressed|
|
|
57
|
+
write(
|
|
58
|
+
:info,
|
|
59
|
+
"Suppressed repeated jsonsizelimit events before shutdown",
|
|
60
|
+
{category: category, suppressed_events: suppressed}
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Observability must never become a data-path or shutdown failure.
|
|
68
|
+
def write(level, message, fields)
|
|
69
|
+
@logger.public_send(level, message, fields)
|
|
70
|
+
rescue
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fluent/plugin/json_size_limit/errors"
|
|
5
|
+
require "fluent/plugin/json_size_limit/string_encoding"
|
|
6
|
+
|
|
7
|
+
module Fluent
|
|
8
|
+
module Plugin
|
|
9
|
+
module JsonSizeLimit
|
|
10
|
+
# Performs a bounded, side-effect-free preflight before using the native
|
|
11
|
+
# JSON generator exactly once. The preflight prevents arbitrary Ruby
|
|
12
|
+
# serializers or traversal overrides from entering the hot path.
|
|
13
|
+
class RecordMeasurer
|
|
14
|
+
EXIT_CONTAINER = Object.new.freeze
|
|
15
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
16
|
+
HASH_LENGTH = Hash.instance_method(:length)
|
|
17
|
+
ARRAY_EACH = Array.instance_method(:each)
|
|
18
|
+
ARRAY_LENGTH = Array.instance_method(:length)
|
|
19
|
+
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
20
|
+
SINGLETON_METHODS = Object.instance_method(:singleton_methods)
|
|
21
|
+
|
|
22
|
+
def initialize(guard)
|
|
23
|
+
@guard = guard
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def measure(record)
|
|
27
|
+
validate_record(record)
|
|
28
|
+
@guard.checkpoint!
|
|
29
|
+
|
|
30
|
+
json_bytes = JSON.generate(record).bytesize
|
|
31
|
+
@guard.enforce_input_bytes!(json_bytes)
|
|
32
|
+
@guard.checkpoint!
|
|
33
|
+
json_bytes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_record(record)
|
|
39
|
+
active_containers = {}.compare_by_identity
|
|
40
|
+
stack = [record, 0]
|
|
41
|
+
|
|
42
|
+
until stack.empty?
|
|
43
|
+
frame_data = stack.pop
|
|
44
|
+
value = stack.pop
|
|
45
|
+
|
|
46
|
+
if value.equal?(EXIT_CONTAINER)
|
|
47
|
+
# Exit frames store the container identity in the same flat stack
|
|
48
|
+
# slot that regular frames use for their numeric depth.
|
|
49
|
+
active_containers.delete(frame_data)
|
|
50
|
+
else
|
|
51
|
+
validate_value(value, frame_data, stack, active_containers)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_value(value, depth, stack, active_containers)
|
|
57
|
+
case value
|
|
58
|
+
when String
|
|
59
|
+
reject_subclass!(value, String)
|
|
60
|
+
StringEncoding.validate!(value)
|
|
61
|
+
@guard.visit!(depth, STRING_BYTESIZE.bind_call(value))
|
|
62
|
+
when Hash
|
|
63
|
+
reject_subclass!(value, Hash)
|
|
64
|
+
validate_hash(value, depth, stack, active_containers)
|
|
65
|
+
when Array
|
|
66
|
+
reject_subclass!(value, Array)
|
|
67
|
+
validate_array(value, depth, stack, active_containers)
|
|
68
|
+
when Integer, Float, Symbol
|
|
69
|
+
reject_subclass!(value, value.class)
|
|
70
|
+
@guard.visit!(depth)
|
|
71
|
+
when true, false, nil
|
|
72
|
+
@guard.visit!(depth)
|
|
73
|
+
else
|
|
74
|
+
raise UnsupportedRecordError, value.class.name
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_hash(hash, depth, stack, active_containers)
|
|
79
|
+
reject_singleton_methods!(hash)
|
|
80
|
+
enter_container!(hash, active_containers)
|
|
81
|
+
length = HASH_LENGTH.bind_call(hash)
|
|
82
|
+
@guard.visit!(depth, 2 + length + [length - 1, 0].max)
|
|
83
|
+
stack << EXIT_CONTAINER << hash
|
|
84
|
+
|
|
85
|
+
HASH_EACH_PAIR.bind_call(hash) do |key, value|
|
|
86
|
+
validate_key(key)
|
|
87
|
+
stack << value << (depth + 1)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_array(array, depth, stack, active_containers)
|
|
92
|
+
reject_singleton_methods!(array)
|
|
93
|
+
enter_container!(array, active_containers)
|
|
94
|
+
length = ARRAY_LENGTH.bind_call(array)
|
|
95
|
+
@guard.visit!(depth, 2 + [length - 1, 0].max)
|
|
96
|
+
stack << EXIT_CONTAINER << array
|
|
97
|
+
ARRAY_EACH.bind_call(array) { |value| stack << value << (depth + 1) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_key(key)
|
|
101
|
+
case key
|
|
102
|
+
when String
|
|
103
|
+
reject_subclass!(key, String)
|
|
104
|
+
StringEncoding.validate!(key)
|
|
105
|
+
@guard.account_input_bytes!(STRING_BYTESIZE.bind_call(key))
|
|
106
|
+
when Symbol, Integer, Float, true, false, nil
|
|
107
|
+
reject_subclass!(key, key.class) unless key.nil?
|
|
108
|
+
else
|
|
109
|
+
raise UnsupportedRecordError, key.class.name
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def enter_container!(container, active_containers)
|
|
114
|
+
raise CyclicRecordError if active_containers.key?(container)
|
|
115
|
+
|
|
116
|
+
active_containers[container] = true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def reject_subclass!(value, expected_class)
|
|
120
|
+
return if value.instance_of?(expected_class)
|
|
121
|
+
|
|
122
|
+
raise UnsupportedRecordError, value.class.name
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def reject_singleton_methods!(value)
|
|
126
|
+
return if SINGLETON_METHODS.bind_call(value, false).empty?
|
|
127
|
+
|
|
128
|
+
raise UnsupportedRecordError, value.class.name
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|