fluent-plugin-gcloud-pubsub-universe-domain 2.0.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 +7 -0
- data/.github/workflows/release.yml +35 -0
- data/CHANGELOG.md +168 -0
- data/Gemfile +8 -0
- data/LICENSE +22 -0
- data/README.md +225 -0
- data/Rakefile +13 -0
- data/fluent-plugin-gcloud-pubsub-universe-domain.gemspec +25 -0
- data/fluent.conf +42 -0
- data/lib/fluent/plugin/gcloud_pubsub/client.rb +91 -0
- data/lib/fluent/plugin/in_gcloud_pubsub.rb +273 -0
- data/lib/fluent/plugin/out_gcloud_pubsub.rb +161 -0
- data/test/helper.rb +23 -0
- data/test/plugin/test_in_gcloud_pubsub.rb +424 -0
- data/test/plugin/test_out_gcloud_pubsub.rb +375 -0
- metadata +148 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'webrick'
|
|
3
|
+
|
|
4
|
+
require 'fluent/plugin/compressable'
|
|
5
|
+
require 'fluent/plugin/input'
|
|
6
|
+
require 'fluent/plugin/parser'
|
|
7
|
+
|
|
8
|
+
require 'fluent/plugin/gcloud_pubsub/client'
|
|
9
|
+
|
|
10
|
+
module Fluent::Plugin
|
|
11
|
+
class GcloudPubSubInput < Input
|
|
12
|
+
include Fluent::Plugin::Compressable
|
|
13
|
+
|
|
14
|
+
Fluent::Plugin.register_input('gcloud_pubsub', self)
|
|
15
|
+
|
|
16
|
+
helpers :compat_parameters, :parser, :thread
|
|
17
|
+
|
|
18
|
+
DEFAULT_PARSER_TYPE = 'json'
|
|
19
|
+
|
|
20
|
+
class FailedParseError < StandardError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc 'Set tag of messages.'
|
|
24
|
+
config_param :tag, :string
|
|
25
|
+
desc 'Set key to be used as tag.'
|
|
26
|
+
config_param :tag_key, :string, default: nil
|
|
27
|
+
desc 'Set your GCP project.'
|
|
28
|
+
config_param :project, :string, default: nil
|
|
29
|
+
desc 'Set your credential file path.'
|
|
30
|
+
config_param :key, :string, default: nil
|
|
31
|
+
desc 'Set topic name to pull.'
|
|
32
|
+
config_param :topic, :string, default: nil
|
|
33
|
+
desc 'Set subscription name to pull.'
|
|
34
|
+
config_param :subscription, :string
|
|
35
|
+
desc 'Pulling messages by intervals of specified seconds.'
|
|
36
|
+
config_param :pull_interval, :float, default: 5.0
|
|
37
|
+
desc 'Max messages pulling at once.'
|
|
38
|
+
config_param :max_messages, :integer, default: 100
|
|
39
|
+
desc 'Setting `true`, keepalive connection to wait for new messages.'
|
|
40
|
+
config_param :return_immediately, :bool, default: true
|
|
41
|
+
desc 'Set number of threads to pull messages.'
|
|
42
|
+
config_param :pull_threads, :integer, default: 1
|
|
43
|
+
desc 'Specify the key of the attribute to be acquired as a record'
|
|
44
|
+
config_param :attribute_keys, :array, default: []
|
|
45
|
+
desc 'Set error type when parsing messages fails.'
|
|
46
|
+
config_param :parse_error_action, :enum, default: :exception, list: [:exception, :warning]
|
|
47
|
+
# for HTTP RPC
|
|
48
|
+
desc 'If `true` is specified, HTTP RPC to stop or start pulling message is enabled.'
|
|
49
|
+
config_param :enable_rpc, :bool, default: false
|
|
50
|
+
desc 'Bind IP address for HTTP RPC.'
|
|
51
|
+
config_param :rpc_bind, :string, default: '0.0.0.0'
|
|
52
|
+
desc 'Port for HTTP RPC.'
|
|
53
|
+
config_param :rpc_port, :integer, default: 24680
|
|
54
|
+
desc 'Decompress messages'
|
|
55
|
+
config_param :decompression, :string, default: nil
|
|
56
|
+
desc 'Set universe domain'
|
|
57
|
+
config_param :universe_domain, :string, default: nil
|
|
58
|
+
|
|
59
|
+
config_section :parse do
|
|
60
|
+
config_set_default :@type, DEFAULT_PARSER_TYPE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class RPCServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
64
|
+
class Error < StandardError; end
|
|
65
|
+
|
|
66
|
+
def initialize(server, plugin)
|
|
67
|
+
super
|
|
68
|
+
@plugin = plugin
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def do_GET(req, res)
|
|
72
|
+
begin
|
|
73
|
+
code, header, body = process(req, res)
|
|
74
|
+
rescue
|
|
75
|
+
code, header, body = render_json(500, {
|
|
76
|
+
'ok' => false,
|
|
77
|
+
'message' => 'Internal Server Error',
|
|
78
|
+
'error' => "#{$!}",
|
|
79
|
+
'backtrace'=> $!.backtrace
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
res.status = code
|
|
84
|
+
header.each_pair {|k,v|
|
|
85
|
+
res[k] = v
|
|
86
|
+
}
|
|
87
|
+
res.body = body
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render_json(code, obj)
|
|
91
|
+
[code, {'Content-Type' => 'application/json'}, obj.to_json]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def process(req, res)
|
|
95
|
+
ret = {'ok' => true}
|
|
96
|
+
case req.path_info
|
|
97
|
+
when '/stop'
|
|
98
|
+
@plugin.stop_pull
|
|
99
|
+
when '/start'
|
|
100
|
+
@plugin.start_pull
|
|
101
|
+
when '/status'
|
|
102
|
+
ret['status'] = @plugin.status_of_pull
|
|
103
|
+
else
|
|
104
|
+
raise Error.new "Invalid path_info: #{req.path_info}"
|
|
105
|
+
end
|
|
106
|
+
render_json(200, ret)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def configure(conf)
|
|
111
|
+
compat_parameters_convert(conf, :parser)
|
|
112
|
+
super
|
|
113
|
+
@rpc_srv = nil
|
|
114
|
+
@rpc_thread = nil
|
|
115
|
+
@stop_pull = false
|
|
116
|
+
|
|
117
|
+
@extract_tag = if @tag_key.nil?
|
|
118
|
+
method(:static_tag)
|
|
119
|
+
else
|
|
120
|
+
method(:dynamic_tag)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@parser = parser_create
|
|
124
|
+
@decompress = if @decompression == 'gzip'
|
|
125
|
+
method(:gzip_decompress)
|
|
126
|
+
else
|
|
127
|
+
method(:no_decompress)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def start
|
|
132
|
+
super
|
|
133
|
+
start_rpc if @enable_rpc
|
|
134
|
+
|
|
135
|
+
@subscriber = Fluent::GcloudPubSub::Subscriber.new @project, @key, @topic, @subscription, @universe_domain
|
|
136
|
+
log.debug "connected subscription:#{@subscription} in project #{@project}"
|
|
137
|
+
|
|
138
|
+
@emit_guard = Mutex.new
|
|
139
|
+
@stop_subscribing = false
|
|
140
|
+
@subscribe_threads = []
|
|
141
|
+
@pull_threads.times do |idx|
|
|
142
|
+
@subscribe_threads.push thread_create("in_gcloud_pubsub_subscribe_#{idx}".to_sym, &method(:subscribe))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def shutdown
|
|
147
|
+
if @rpc_srv
|
|
148
|
+
@rpc_srv.shutdown
|
|
149
|
+
@rpc_srv = nil
|
|
150
|
+
end
|
|
151
|
+
if @rpc_thread
|
|
152
|
+
@rpc_thread = nil
|
|
153
|
+
end
|
|
154
|
+
@stop_subscribing = true
|
|
155
|
+
@subscribe_threads.each(&:join)
|
|
156
|
+
super
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def stop_pull
|
|
160
|
+
@stop_pull = true
|
|
161
|
+
log.info "stop pull from subscription:#{@subscription}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def start_pull
|
|
165
|
+
@stop_pull = false
|
|
166
|
+
log.info "start pull from subscription:#{@subscription}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def status_of_pull
|
|
170
|
+
@stop_pull ? 'stopped' : 'started'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def static_tag(record)
|
|
176
|
+
@tag
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def dynamic_tag(record)
|
|
180
|
+
record.delete(@tag_key) || @tag
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def gzip_decompress(message)
|
|
184
|
+
decompress message
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def no_decompress(message)
|
|
188
|
+
message
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def start_rpc
|
|
192
|
+
log.info "listening http rpc server on http://#{@rpc_bind}:#{@rpc_port}/"
|
|
193
|
+
@rpc_srv = WEBrick::HTTPServer.new(
|
|
194
|
+
{
|
|
195
|
+
BindAddress: @rpc_bind,
|
|
196
|
+
Port: @rpc_port,
|
|
197
|
+
Logger: WEBrick::Log.new(STDERR, WEBrick::Log::FATAL),
|
|
198
|
+
AccessLog: []
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
@rpc_srv.mount('/api/in_gcloud_pubsub/pull/', RPCServlet, self)
|
|
202
|
+
@rpc_thread = thread_create(:in_gcloud_pubsub_rpc_thread){
|
|
203
|
+
@rpc_srv.start
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def subscribe
|
|
208
|
+
until @stop_subscribing
|
|
209
|
+
_subscribe unless @stop_pull
|
|
210
|
+
|
|
211
|
+
if @return_immediately || @stop_pull
|
|
212
|
+
sleep @pull_interval
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
rescue => ex
|
|
216
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
|
217
|
+
log.error_backtrace ex.backtrace
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def _subscribe
|
|
221
|
+
messages = @subscriber.pull @return_immediately, @max_messages
|
|
222
|
+
if messages.length == 0
|
|
223
|
+
log.debug "no messages are pulled"
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
process messages
|
|
228
|
+
@subscriber.acknowledge messages
|
|
229
|
+
|
|
230
|
+
log.debug "#{messages.length} message(s) processed"
|
|
231
|
+
rescue Fluent::GcloudPubSub::RetryableError => ex
|
|
232
|
+
log.warn "Retryable error occurs. Fluentd will retry.", error_message: ex.to_s, error_class: ex.class.to_s
|
|
233
|
+
rescue => ex
|
|
234
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
|
235
|
+
log.error_backtrace ex.backtrace
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def process(messages)
|
|
239
|
+
event_streams = Hash.new do |hsh, key|
|
|
240
|
+
hsh[key] = Fluent::MultiEventStream.new
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
messages.each do |m|
|
|
244
|
+
line = @decompress.call(m.message.data).chomp
|
|
245
|
+
attributes = m.attributes
|
|
246
|
+
@parser.parse(line) do |time, record|
|
|
247
|
+
if time && record
|
|
248
|
+
@attribute_keys.each do |key|
|
|
249
|
+
record[key] = attributes[key]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
event_streams[@extract_tag.call(record)].add(time, record)
|
|
253
|
+
else
|
|
254
|
+
case @parse_error_action
|
|
255
|
+
when :exception
|
|
256
|
+
raise FailedParseError.new "pattern not match: #{line}"
|
|
257
|
+
else
|
|
258
|
+
log.warn 'pattern not match', record: line
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
event_streams.each do |tag, es|
|
|
265
|
+
# There are some output plugins not to supposed to be called with multi-threading.
|
|
266
|
+
# Maybe remove in the future.
|
|
267
|
+
@emit_guard.synchronize do
|
|
268
|
+
router.emit_stream(tag, es)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require 'fluent/plugin/compressable'
|
|
2
|
+
require 'fluent/plugin/output'
|
|
3
|
+
require 'fluent/plugin/gcloud_pubsub/client'
|
|
4
|
+
require 'fluent/plugin_helper/inject'
|
|
5
|
+
|
|
6
|
+
module Fluent::Plugin
|
|
7
|
+
class GcloudPubSubOutput < Output
|
|
8
|
+
include Fluent::Plugin::Compressable
|
|
9
|
+
include Fluent::PluginHelper::Inject
|
|
10
|
+
|
|
11
|
+
Fluent::Plugin.register_output('gcloud_pubsub', self)
|
|
12
|
+
|
|
13
|
+
helpers :compat_parameters, :formatter
|
|
14
|
+
|
|
15
|
+
DEFAULT_BUFFER_TYPE = "memory"
|
|
16
|
+
DEFAULT_FORMATTER_TYPE = "json"
|
|
17
|
+
|
|
18
|
+
desc 'Set your GCP project.'
|
|
19
|
+
config_param :project, :string, :default => nil
|
|
20
|
+
desc 'Set your credential file path.'
|
|
21
|
+
config_param :key, :string, :default => nil
|
|
22
|
+
desc 'Set topic name to publish.'
|
|
23
|
+
config_param :topic, :string
|
|
24
|
+
desc "Set your dest GCP project if publishing cross project"
|
|
25
|
+
config_param :dest_project, :string, :default => nil
|
|
26
|
+
desc "If set to `true`, specified topic will be created when it doesn't exist."
|
|
27
|
+
config_param :autocreate_topic, :bool, :default => false
|
|
28
|
+
desc 'Publishing messages count per request to Cloud Pub/Sub.'
|
|
29
|
+
config_param :max_messages, :integer, :default => 1000
|
|
30
|
+
desc 'Publishing messages bytesize per request to Cloud Pub/Sub.'
|
|
31
|
+
config_param :max_total_size, :integer, :default => 9800000 # 9.8MB
|
|
32
|
+
desc 'Limit bytesize per message.'
|
|
33
|
+
config_param :max_message_size, :integer, :default => 4000000 # 4MB
|
|
34
|
+
desc 'Publishing the set field as an attribute created from input message'
|
|
35
|
+
config_param :attribute_keys, :array, :default => []
|
|
36
|
+
desc 'Publishing the set field as an attribute created from input config params'
|
|
37
|
+
config_param :attribute_key_values, :hash, :default => {}
|
|
38
|
+
desc 'Set service endpoint'
|
|
39
|
+
config_param :endpoint, :string, :default => nil
|
|
40
|
+
desc 'Compress messages'
|
|
41
|
+
config_param :compression, :string, :default => nil
|
|
42
|
+
desc 'Set default emulator_host to use in publish requests'
|
|
43
|
+
config_param :emulator_host, :string, :default => nil
|
|
44
|
+
desc 'Set default timeout to use in publish requests'
|
|
45
|
+
config_param :timeout, :integer, :default => nil
|
|
46
|
+
desc 'Set universe domain'
|
|
47
|
+
config_param :universe_domain, :string, :default => nil
|
|
48
|
+
|
|
49
|
+
config_section :buffer do
|
|
50
|
+
config_set_default :@type, DEFAULT_BUFFER_TYPE
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
config_section :format do
|
|
54
|
+
config_set_default :@type, DEFAULT_FORMATTER_TYPE
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def configure(conf)
|
|
58
|
+
compat_parameters_convert(conf, :buffer, :formatter)
|
|
59
|
+
super
|
|
60
|
+
placeholder_validate!(:topic, @topic)
|
|
61
|
+
@formatter = formatter_create
|
|
62
|
+
@compress = if @compression == 'gzip'
|
|
63
|
+
method(:gzip_compress)
|
|
64
|
+
else
|
|
65
|
+
method(:no_compress)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def start
|
|
70
|
+
super
|
|
71
|
+
@publisher = Fluent::GcloudPubSub::Publisher.new @project, @key, @autocreate_topic, @dest_project, @endpoint, @timeout, @emulator_host, @universe_domain
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def format(tag, time, record)
|
|
75
|
+
record = inject_values_to_record(tag, time, record)
|
|
76
|
+
record = encode_utf8(record)
|
|
77
|
+
attributes = {}
|
|
78
|
+
@attribute_keys.each do |key|
|
|
79
|
+
attributes[key] = record.delete(key)
|
|
80
|
+
end
|
|
81
|
+
attributes.merge! @attribute_key_values
|
|
82
|
+
[@compress.call(@formatter.format(tag, time, record)), attributes].to_msgpack
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Recursively convert ASCII-8BIT encoded strings to UTF-8 so the JSON
|
|
86
|
+
# formatter can serialize them (JSON.generate raises on ASCII-8BIT strings
|
|
87
|
+
# containing multibyte characters).
|
|
88
|
+
def encode_utf8(value)
|
|
89
|
+
case value
|
|
90
|
+
when String
|
|
91
|
+
if value.encoding == Encoding::ASCII_8BIT
|
|
92
|
+
value.dup.force_encoding("UTF-8")
|
|
93
|
+
else
|
|
94
|
+
value
|
|
95
|
+
end
|
|
96
|
+
when Hash
|
|
97
|
+
value.transform_values { |v| encode_utf8(v) }
|
|
98
|
+
when Array
|
|
99
|
+
value.map { |v| encode_utf8(v) }
|
|
100
|
+
else
|
|
101
|
+
value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def formatted_to_msgpack_binary?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def multi_workers_ready?
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def write(chunk)
|
|
114
|
+
topic = extract_placeholders(@topic, chunk.metadata)
|
|
115
|
+
|
|
116
|
+
messages = []
|
|
117
|
+
size = 0
|
|
118
|
+
|
|
119
|
+
chunk.msgpack_each do |msg, attr|
|
|
120
|
+
msg = Fluent::GcloudPubSub::Message.new(msg, attr)
|
|
121
|
+
if msg.bytesize > @max_message_size
|
|
122
|
+
log.warn 'Drop a message because its size exceeds `max_message_size`', size: msg.bytesize
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
if messages.length + 1 > @max_messages || size + msg.bytesize > @max_total_size
|
|
126
|
+
publish(topic, messages)
|
|
127
|
+
messages = []
|
|
128
|
+
size = 0
|
|
129
|
+
end
|
|
130
|
+
messages << msg
|
|
131
|
+
size += msg.bytesize
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if messages.length > 0
|
|
135
|
+
publish(topic, messages)
|
|
136
|
+
end
|
|
137
|
+
rescue Fluent::GcloudPubSub::RetryableError => ex
|
|
138
|
+
log.warn "Retryable error occurs. Fluentd will retry.", error_message: ex.to_s, error_class: ex.class.to_s
|
|
139
|
+
raise ex
|
|
140
|
+
rescue => ex
|
|
141
|
+
log.error "unexpected error", error_message: ex.to_s, error_class: ex.class.to_s
|
|
142
|
+
log.error_backtrace
|
|
143
|
+
raise ex
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def publish(topic, messages)
|
|
149
|
+
log.debug "send message topic:#{topic} length:#{messages.length} size:#{messages.map(&:bytesize).inject(:+)}"
|
|
150
|
+
@publisher.publish(topic, messages)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def gzip_compress(message)
|
|
154
|
+
compress message
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def no_compress(message)
|
|
158
|
+
message
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/test/helper.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'rubygems'
|
|
2
|
+
require 'bundler'
|
|
3
|
+
begin
|
|
4
|
+
Bundler.setup(:default, :development)
|
|
5
|
+
rescue Bundler::BundlerError => e
|
|
6
|
+
$stderr.puts e.message
|
|
7
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
|
8
|
+
exit e.status_code
|
|
9
|
+
end
|
|
10
|
+
require 'test/unit'
|
|
11
|
+
require "test/unit/rr"
|
|
12
|
+
|
|
13
|
+
$LOAD_PATH.unshift(File.expand_path("../../", __FILE__))
|
|
14
|
+
|
|
15
|
+
require "fluent/test"
|
|
16
|
+
require "fluent/test/driver/output"
|
|
17
|
+
require "fluent/test/helpers"
|
|
18
|
+
|
|
19
|
+
require 'fluent/plugin/in_gcloud_pubsub'
|
|
20
|
+
require 'fluent/plugin/out_gcloud_pubsub'
|
|
21
|
+
|
|
22
|
+
Test::Unit::TestCase.include(Fluent::Test::Helpers)
|
|
23
|
+
Test::Unit::TestCase.extend(Fluent::Test::Helpers)
|