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.
@@ -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