vmik-fluent-plugin-google-cloud 0.5.5.alpha1
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.
- data/CONTRIBUTING +24 -0
- data/Gemfile +3 -0
- data/LICENSE +201 -0
- data/README.rdoc +46 -0
- data/Rakefile +42 -0
- data/fluent-plugin-google-cloud.gemspec +34 -0
- data/lib/fluent/plugin/out_google_cloud.rb +1184 -0
- data/lib/google/logging/type/http_request_pb.rb +30 -0
- data/lib/google/logging/type/log_severity_pb.rb +26 -0
- data/lib/google/logging/v1/log_entry_pb.rb +52 -0
- data/lib/google/logging/v1/logging_pb.rb +84 -0
- data/lib/google/logging/v1/logging_services_pb.rb +150 -0
- data/test/helper.rb +40 -0
- data/test/plugin/base_test.rb +1534 -0
- data/test/plugin/data/c31e573fd7f62ed495c9ca3821a5a85cb036dee1-privatekey.p12 +0 -0
- data/test/plugin/data/credentials.json +7 -0
- data/test/plugin/data/iam-credentials.json +11 -0
- data/test/plugin/data/invalid_credentials.json +8 -0
- data/test/plugin/test_out_google_cloud.rb +297 -0
- data/test/plugin/test_out_google_cloud_grpc.rb +381 -0
- data/vmik-fluent-plugin-google-cloud-0.5.5.gem +0 -0
- metadata +291 -0
@@ -0,0 +1,1184 @@
|
|
1
|
+
# Copyright 2014 Google Inc. All rights reserved.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
require 'grpc'
|
15
|
+
require 'json'
|
16
|
+
require 'open-uri'
|
17
|
+
require 'socket'
|
18
|
+
require 'time'
|
19
|
+
require 'yaml'
|
20
|
+
require 'google/apis'
|
21
|
+
require 'google/apis/logging_v1beta3'
|
22
|
+
require 'google/logging/v1/logging_pb'
|
23
|
+
require 'google/logging/v1/logging_services_pb'
|
24
|
+
require 'google/logging/v1/log_entry_pb'
|
25
|
+
require 'googleauth'
|
26
|
+
|
27
|
+
module Google
|
28
|
+
module Protobuf
|
29
|
+
# Alias the has_key? method to have the same interface as a regular map.
|
30
|
+
class Map
|
31
|
+
alias_method :key?, :has_key?
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
module Fluent
|
37
|
+
# fluentd output plugin for the Stackdriver Logging API
|
38
|
+
class GoogleCloudOutput < BufferedOutput
|
39
|
+
Fluent::Plugin.register_output('google_cloud', self)
|
40
|
+
|
41
|
+
PLUGIN_NAME = 'Fluentd Google Cloud Logging plugin'
|
42
|
+
PLUGIN_VERSION = '0.5.5'
|
43
|
+
|
44
|
+
# Constants for service names.
|
45
|
+
APPENGINE_SERVICE = 'appengine.googleapis.com'
|
46
|
+
CLOUDFUNCTIONS_SERVICE = 'cloudfunctions.googleapis.com'
|
47
|
+
COMPUTE_SERVICE = 'compute.googleapis.com'
|
48
|
+
CONTAINER_SERVICE = 'container.googleapis.com'
|
49
|
+
EC2_SERVICE = 'ec2.amazonaws.com'
|
50
|
+
|
51
|
+
# Name of the the Google cloud logging write scope.
|
52
|
+
LOGGING_SCOPE = 'https://www.googleapis.com/auth/logging.write'
|
53
|
+
|
54
|
+
# Address of the metadata service.
|
55
|
+
METADATA_SERVICE_ADDR = '169.254.169.254'
|
56
|
+
|
57
|
+
# Disable this warning to conform to fluentd config_param conventions.
|
58
|
+
# rubocop:disable Style/HashSyntax
|
59
|
+
|
60
|
+
# Specify project/instance metadata.
|
61
|
+
#
|
62
|
+
# project_id, zone, and vm_id are required to have valid values, which
|
63
|
+
# can be obtained from the metadata service or set explicitly.
|
64
|
+
# Otherwise, the plugin will fail to initialize.
|
65
|
+
#
|
66
|
+
# Note that while 'project id' properly refers to the alphanumeric name
|
67
|
+
# of the project, the logging service will also accept the project number,
|
68
|
+
# so either one is acceptable in this context.
|
69
|
+
#
|
70
|
+
# Whether to attempt to obtain metadata from the local metadata service.
|
71
|
+
# It is safe to specify 'true' even on platforms with no metadata service.
|
72
|
+
config_param :use_metadata_service, :bool, :default => true
|
73
|
+
# These parameters override any values obtained from the metadata service.
|
74
|
+
config_param :project_id, :string, :default => nil
|
75
|
+
config_param :zone, :string, :default => nil
|
76
|
+
config_param :vm_id, :string, :default => nil
|
77
|
+
config_param :vm_name, :string, :default => nil
|
78
|
+
|
79
|
+
# Whether to try to detect if the VM is owned by a "subservice" such as App
|
80
|
+
# Engine of Kubernetes, rather than just associating the logs with the
|
81
|
+
# compute service of the platform. This currently only has any effect when
|
82
|
+
# running on GCE.
|
83
|
+
#
|
84
|
+
# The initial motivation for this is to separate out Kubernetes node
|
85
|
+
# component (Docker, Kubelet, etc.) logs from container logs.
|
86
|
+
config_param :detect_subservice, :bool, :default => true
|
87
|
+
# The subservice_name overrides the subservice detection, if provided.
|
88
|
+
config_param :subservice_name, :string, :default => nil
|
89
|
+
|
90
|
+
# Whether to reject log entries with invalid tags. If this option is set to
|
91
|
+
# false, tags will be made valid by converting any non-string tag to a
|
92
|
+
# string, and sanitizing any non-utf8 or other invalid characters.
|
93
|
+
config_param :require_valid_tags, :bool, :default => false
|
94
|
+
|
95
|
+
# The regular expression to use on Kubernetes logs to extract some basic
|
96
|
+
# information about the log source. The regex must contain capture groups
|
97
|
+
# for pod_name, namespace_name, and container_name.
|
98
|
+
config_param :kubernetes_tag_regexp, :string, :default =>
|
99
|
+
'\.(?<pod_name>[^_]+)_(?<namespace_name>[^_]+)_(?<container_name>.+)$'
|
100
|
+
|
101
|
+
# label_map (specified as a JSON object) is an unordered set of fluent
|
102
|
+
# field names whose values are sent as labels rather than as part of the
|
103
|
+
# struct payload.
|
104
|
+
#
|
105
|
+
# Each entry in the map is a {"field_name": "label_name"} pair. When
|
106
|
+
# the "field_name" (as parsed by the input plugin) is encountered, a label
|
107
|
+
# with the corresponding "label_name" is added to the log entry. The
|
108
|
+
# value of the field is used as the value of the label.
|
109
|
+
#
|
110
|
+
# The map gives the user additional flexibility in specifying label
|
111
|
+
# names, including the ability to use characters which would not be
|
112
|
+
# legal as part of fluent field names.
|
113
|
+
#
|
114
|
+
# Example:
|
115
|
+
# label_map {
|
116
|
+
# "field_name_1": "sent_label_name_1",
|
117
|
+
# "field_name_2": "some.prefix/sent_label_name_2"
|
118
|
+
# }
|
119
|
+
config_param :label_map, :hash, :default => nil
|
120
|
+
|
121
|
+
# labels (specified as a JSON object) is a set of custom labels
|
122
|
+
# provided at configuration time. It allows users to inject extra
|
123
|
+
# environmental information into every message or to customize
|
124
|
+
# labels otherwise detected automatically.
|
125
|
+
#
|
126
|
+
# Each entry in the map is a {"label_name": "label_value"} pair.
|
127
|
+
#
|
128
|
+
# Example:
|
129
|
+
# labels {
|
130
|
+
# "label_name_1": "label_value_1",
|
131
|
+
# "label_name_2": "label_value_2"
|
132
|
+
# }
|
133
|
+
config_param :labels, :hash, :default => nil
|
134
|
+
|
135
|
+
# Whether to use gRPC instead of REST/JSON to communicate to the
|
136
|
+
# Cloud Logging API.
|
137
|
+
config_param :use_grpc, :bool, :default => false
|
138
|
+
|
139
|
+
# Whether to allow non-UTF-8 characters in user logs. If set to true, any
|
140
|
+
# non-UTF-8 character would be replaced by the string specified by
|
141
|
+
# 'non_utf8_replacement_string'. If set to false, any non-UTF-8 character
|
142
|
+
# would trigger the plugin to error out.
|
143
|
+
config_param :coerce_to_utf8, :bool, :default => true
|
144
|
+
|
145
|
+
# If 'coerce_to_utf8' is set to true, any non-UTF-8 character would be
|
146
|
+
# replaced by the string specified here.
|
147
|
+
config_param :non_utf8_replacement_string, :string, :default => ' '
|
148
|
+
|
149
|
+
# DEPRECATED: The following parameters, if present in the config
|
150
|
+
# indicate that the plugin configuration must be updated.
|
151
|
+
config_param :auth_method, :string, :default => nil
|
152
|
+
config_param :private_key_email, :string, :default => nil
|
153
|
+
config_param :private_key_path, :string, :default => nil
|
154
|
+
config_param :private_key_passphrase, :string,
|
155
|
+
:default => nil,
|
156
|
+
:secret => true
|
157
|
+
|
158
|
+
# rubocop:enable Style/HashSyntax
|
159
|
+
|
160
|
+
# TODO: Add a log_name config option rather than just using the tag?
|
161
|
+
|
162
|
+
# Expose attr_readers to make testing of metadata more direct than only
|
163
|
+
# testing it indirectly through metadata sent with logs.
|
164
|
+
attr_reader :project_id
|
165
|
+
attr_reader :zone
|
166
|
+
attr_reader :vm_id
|
167
|
+
attr_reader :running_on_managed_vm
|
168
|
+
attr_reader :gae_backend_name
|
169
|
+
attr_reader :gae_backend_version
|
170
|
+
attr_reader :service_name
|
171
|
+
attr_reader :common_labels
|
172
|
+
|
173
|
+
def initialize
|
174
|
+
super
|
175
|
+
# use the global logger
|
176
|
+
@log = $log # rubocop:disable Style/GlobalVars
|
177
|
+
end
|
178
|
+
|
179
|
+
def configure(conf)
|
180
|
+
super
|
181
|
+
|
182
|
+
# Alert on old authentication configuration.
|
183
|
+
unless @auth_method.nil? && @private_key_email.nil? &&
|
184
|
+
@private_key_path.nil? && @private_key_passphrase.nil?
|
185
|
+
extra = []
|
186
|
+
extra << 'auth_method' unless @auth_method.nil?
|
187
|
+
extra << 'private_key_email' unless @private_key_email.nil?
|
188
|
+
extra << 'private_key_path' unless @private_key_path.nil?
|
189
|
+
extra << 'private_key_passphrase' unless @private_key_passphrase.nil?
|
190
|
+
|
191
|
+
fail Fluent::ConfigError,
|
192
|
+
"#{PLUGIN_NAME} no longer supports auth_method.\n" \
|
193
|
+
'Please remove configuration parameters: ' +
|
194
|
+
extra.join(' ')
|
195
|
+
end
|
196
|
+
|
197
|
+
# TODO: Send instance tags as labels as well?
|
198
|
+
@common_labels = {}
|
199
|
+
@common_labels.merge!(@labels) if @labels
|
200
|
+
|
201
|
+
@compiled_kubernetes_tag_regexp = nil
|
202
|
+
if @kubernetes_tag_regexp
|
203
|
+
@compiled_kubernetes_tag_regexp = Regexp.new(@kubernetes_tag_regexp)
|
204
|
+
end
|
205
|
+
|
206
|
+
@cloudfunctions_tag_regexp =
|
207
|
+
/\.(?<encoded_function_name>.+)\.\d+-[^-]+_default_worker$/
|
208
|
+
@cloudfunctions_log_regexp = /^
|
209
|
+
(?:\[(?<severity>.)\])?
|
210
|
+
\[(?<timestamp>.{24})\]
|
211
|
+
(?:\[(?<execution_id>[^\]]+)\])?
|
212
|
+
[ ](?<text>.*)$/x
|
213
|
+
|
214
|
+
# set attributes from metadata (unless overriden by static config)
|
215
|
+
@vm_name = Socket.gethostname if @vm_name.nil?
|
216
|
+
@platform = detect_platform
|
217
|
+
case @platform
|
218
|
+
when Platform::GCE
|
219
|
+
if @project_id.nil?
|
220
|
+
@project_id = fetch_gce_metadata('project/project-id')
|
221
|
+
end
|
222
|
+
if @zone.nil?
|
223
|
+
# this returns "projects/<number>/zones/<zone>"; we only want
|
224
|
+
# the part after the final slash.
|
225
|
+
fully_qualified_zone = fetch_gce_metadata('instance/zone')
|
226
|
+
@zone = fully_qualified_zone.rpartition('/')[2]
|
227
|
+
end
|
228
|
+
@vm_id = fetch_gce_metadata('instance/id') if @vm_id.nil?
|
229
|
+
when Platform::EC2
|
230
|
+
metadata = fetch_ec2_metadata
|
231
|
+
if @zone.nil? && metadata.key?('availabilityZone')
|
232
|
+
@zone = 'aws:' + metadata['availabilityZone']
|
233
|
+
end
|
234
|
+
if @vm_id.nil? && metadata.key?('instanceId')
|
235
|
+
@vm_id = metadata['instanceId']
|
236
|
+
end
|
237
|
+
if metadata.key?('accountId')
|
238
|
+
common_labels["#{EC2_SERVICE}/account_id"] = metadata['accountId']
|
239
|
+
end
|
240
|
+
when Platform::OTHER
|
241
|
+
# do nothing
|
242
|
+
else
|
243
|
+
fail Fluent::ConfigError, 'Unknown platform ' + @platform
|
244
|
+
end
|
245
|
+
|
246
|
+
# If we still don't have a project ID, try to obtain it from the
|
247
|
+
# credentials.
|
248
|
+
if @project_id.nil?
|
249
|
+
@project_id = CredentialsInfo.project_id
|
250
|
+
@log.info 'Set Project ID from credentials: ', @project_id unless
|
251
|
+
@project_id.nil?
|
252
|
+
end
|
253
|
+
|
254
|
+
# all metadata parameters must now be set
|
255
|
+
unless @project_id && @zone && @vm_id
|
256
|
+
missing = []
|
257
|
+
missing << 'project_id' unless @project_id
|
258
|
+
missing << 'zone' unless @zone
|
259
|
+
missing << 'vm_id' unless @vm_id
|
260
|
+
fail Fluent::ConfigError, 'Unable to obtain metadata parameters: ' +
|
261
|
+
missing.join(' ')
|
262
|
+
end
|
263
|
+
|
264
|
+
# Default this to false; it is only overwritten if we detect Managed VM.
|
265
|
+
@running_on_managed_vm = false
|
266
|
+
|
267
|
+
# Default this to false; it is only overwritten if we detect Cloud
|
268
|
+
# Functions.
|
269
|
+
@running_cloudfunctions = false
|
270
|
+
|
271
|
+
# Set labels, etc. based on the config
|
272
|
+
case @platform
|
273
|
+
when Platform::GCE
|
274
|
+
@service_name = COMPUTE_SERVICE
|
275
|
+
if @subservice_name
|
276
|
+
@service_name = @subservice_name
|
277
|
+
elsif @detect_subservice
|
278
|
+
# Check for specialized GCE environments.
|
279
|
+
# TODO: Add config options for these to allow for running outside GCE?
|
280
|
+
attributes = fetch_gce_metadata('instance/attributes/').split
|
281
|
+
# Do nothing, just don't populate other service's labels.
|
282
|
+
if attributes.include?('gae_backend_name') &&
|
283
|
+
attributes.include?('gae_backend_version')
|
284
|
+
# Managed VM
|
285
|
+
@running_on_managed_vm = true
|
286
|
+
@gae_backend_name =
|
287
|
+
fetch_gce_metadata('instance/attributes/gae_backend_name')
|
288
|
+
@gae_backend_version =
|
289
|
+
fetch_gce_metadata('instance/attributes/gae_backend_version')
|
290
|
+
@service_name = APPENGINE_SERVICE
|
291
|
+
common_labels["#{APPENGINE_SERVICE}/module_id"] = @gae_backend_name
|
292
|
+
common_labels["#{APPENGINE_SERVICE}/version_id"] =
|
293
|
+
@gae_backend_version
|
294
|
+
elsif attributes.include?('kube-env')
|
295
|
+
# Kubernetes/Container Engine
|
296
|
+
@service_name = CONTAINER_SERVICE
|
297
|
+
common_labels["#{CONTAINER_SERVICE}/instance_id"] = @vm_id
|
298
|
+
@raw_kube_env = fetch_gce_metadata('instance/attributes/kube-env')
|
299
|
+
@kube_env = YAML.load(@raw_kube_env)
|
300
|
+
common_labels["#{CONTAINER_SERVICE}/cluster_name"] =
|
301
|
+
cluster_name_from_kube_env(@kube_env)
|
302
|
+
detect_cloudfunctions(attributes)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
common_labels["#{COMPUTE_SERVICE}/resource_type"] = 'instance'
|
306
|
+
common_labels["#{COMPUTE_SERVICE}/resource_id"] = @vm_id
|
307
|
+
common_labels["#{COMPUTE_SERVICE}/resource_name"] = @vm_name
|
308
|
+
when Platform::EC2
|
309
|
+
@service_name = EC2_SERVICE
|
310
|
+
common_labels["#{EC2_SERVICE}/resource_type"] = 'instance'
|
311
|
+
common_labels["#{EC2_SERVICE}/resource_id"] = @vm_id
|
312
|
+
common_labels["#{EC2_SERVICE}/resource_name"] = @vm_name
|
313
|
+
when Platform::OTHER
|
314
|
+
# Use COMPUTE_SERVICE as the default environment.
|
315
|
+
@service_name = COMPUTE_SERVICE
|
316
|
+
common_labels["#{COMPUTE_SERVICE}/resource_type"] = 'instance'
|
317
|
+
common_labels["#{COMPUTE_SERVICE}/resource_id"] = @vm_id
|
318
|
+
common_labels["#{COMPUTE_SERVICE}/resource_name"] = @vm_name
|
319
|
+
end
|
320
|
+
|
321
|
+
# Log an informational message containing the Logs viewer URL
|
322
|
+
@log.info 'Logs viewer address: ',
|
323
|
+
'https://console.developers.google.com/project/', @project_id,
|
324
|
+
'/logs?service=', @service_name, '&key1=instance&key2=', @vm_id
|
325
|
+
end
|
326
|
+
|
327
|
+
def start
|
328
|
+
super
|
329
|
+
init_api_client
|
330
|
+
@successful_call = false
|
331
|
+
@timenanos_warning = false
|
332
|
+
end
|
333
|
+
|
334
|
+
def shutdown
|
335
|
+
super
|
336
|
+
end
|
337
|
+
|
338
|
+
def format(tag, time, record)
|
339
|
+
[tag, time, record].to_msgpack
|
340
|
+
end
|
341
|
+
|
342
|
+
# Given a tag, returns the corresponding valid tag if possible, or nil if
|
343
|
+
# the tag should be rejected. If 'require_valid_tags' is false, non-string
|
344
|
+
# tags are converted to strings, and invalid characters are sanitized;
|
345
|
+
# otherwise such tags are rejected.
|
346
|
+
def sanitize_tag(tag)
|
347
|
+
if @require_valid_tags &&
|
348
|
+
(!tag.is_a?(String) || tag == '' || convert_to_utf8(tag) != tag)
|
349
|
+
return nil
|
350
|
+
end
|
351
|
+
tag = convert_to_utf8(tag.to_s)
|
352
|
+
tag = '_' if tag == ''
|
353
|
+
tag
|
354
|
+
end
|
355
|
+
|
356
|
+
def write(chunk)
|
357
|
+
# Group the entries since we have to make one call per tag.
|
358
|
+
grouped_entries = {}
|
359
|
+
chunk.msgpack_each do |tag, *arr|
|
360
|
+
sanitized_tag = sanitize_tag(tag)
|
361
|
+
if sanitized_tag.nil?
|
362
|
+
@log.warn "Dropping log entries with invalid tag: '#{tag}'. " \
|
363
|
+
'A tag should be a string with utf8 characters.'
|
364
|
+
next
|
365
|
+
end
|
366
|
+
grouped_entries[sanitized_tag] ||= []
|
367
|
+
grouped_entries[sanitized_tag].push(arr)
|
368
|
+
end
|
369
|
+
|
370
|
+
grouped_entries.each do |tag, arr|
|
371
|
+
entries = []
|
372
|
+
labels = @common_labels.clone
|
373
|
+
|
374
|
+
if @running_cloudfunctions
|
375
|
+
# If the current group of entries is coming from a Cloud Functions
|
376
|
+
# function, the function name can be extracted from the tag.
|
377
|
+
match_data = @cloudfunctions_tag_regexp.match(tag)
|
378
|
+
if match_data
|
379
|
+
# Service name is set to Cloud Functions only for logs actually
|
380
|
+
# coming from a function.
|
381
|
+
@service_name = CLOUDFUNCTIONS_SERVICE
|
382
|
+
labels["#{CLOUDFUNCTIONS_SERVICE}/region"] = @gcf_region
|
383
|
+
labels["#{CLOUDFUNCTIONS_SERVICE}/function_name"] =
|
384
|
+
decode_cloudfunctions_function_name(
|
385
|
+
match_data['encoded_function_name'])
|
386
|
+
else
|
387
|
+
# Other logs are considered as coming from the Container Engine
|
388
|
+
# service.
|
389
|
+
@service_name = CONTAINER_SERVICE
|
390
|
+
end
|
391
|
+
end
|
392
|
+
if @service_name == CONTAINER_SERVICE && @compiled_kubernetes_tag_regexp
|
393
|
+
# Container logs in Kubernetes are tagged based on where they came
|
394
|
+
# from, so we can extract useful metadata from the tag.
|
395
|
+
# Do this here to avoid having to repeat it for each record.
|
396
|
+
match_data = @compiled_kubernetes_tag_regexp.match(tag)
|
397
|
+
if match_data
|
398
|
+
%w(namespace_name pod_name container_name).each do |field|
|
399
|
+
labels["#{CONTAINER_SERVICE}/#{field}"] = match_data[field]
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
arr.each do |time, record|
|
405
|
+
next unless record.is_a?(Hash)
|
406
|
+
|
407
|
+
if @use_grpc
|
408
|
+
entry = Google::Logging::V1::LogEntry.new(
|
409
|
+
metadata: Google::Logging::V1::LogEntryMetadata.new(
|
410
|
+
service_name: convert_to_utf8(@service_name),
|
411
|
+
project_id: convert_to_utf8(@project_id),
|
412
|
+
zone: convert_to_utf8(@zone),
|
413
|
+
labels: {}
|
414
|
+
))
|
415
|
+
else
|
416
|
+
entry = Google::Apis::LoggingV1beta3::LogEntry.new(
|
417
|
+
metadata: Google::Apis::LoggingV1beta3::LogEntryMetadata.new(
|
418
|
+
service_name: @service_name,
|
419
|
+
project_id: @project_id,
|
420
|
+
zone: @zone,
|
421
|
+
labels: {}
|
422
|
+
))
|
423
|
+
end
|
424
|
+
|
425
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE && record.key?('log')
|
426
|
+
@cloudfunctions_log_match =
|
427
|
+
@cloudfunctions_log_regexp.match(record['log'])
|
428
|
+
end
|
429
|
+
if @service_name == CONTAINER_SERVICE
|
430
|
+
# Move the stdout/stderr annotation from the record into a label.
|
431
|
+
field_to_label(record, 'stream', entry.metadata.labels,
|
432
|
+
"#{CONTAINER_SERVICE}/stream")
|
433
|
+
# If the record has been annotated by the kubernetes_metadata_filter
|
434
|
+
# plugin, then use that metadata. Otherwise, rely on commonLabels
|
435
|
+
# populated at the grouped_entries level from the group's tag.
|
436
|
+
if record.key?('kubernetes')
|
437
|
+
handle_container_metadata(record, entry)
|
438
|
+
end
|
439
|
+
|
440
|
+
# Save the timestamp if available, then clear it out to allow for
|
441
|
+
# determining whether we should parse the log or message field.
|
442
|
+
timestamp = record.key?('time') ? record['time'] : nil
|
443
|
+
record.delete('time')
|
444
|
+
# If the log is json, we want to export it as a structured log
|
445
|
+
# unless there is additional metadata that would be lost.
|
446
|
+
is_json = false
|
447
|
+
if record.length == 1 && record.key?('log')
|
448
|
+
record_json = parse_json_or_nil(record['log'])
|
449
|
+
end
|
450
|
+
if record.length == 1 && record.key?('message')
|
451
|
+
record_json = parse_json_or_nil(record['message'])
|
452
|
+
end
|
453
|
+
unless record_json.nil?
|
454
|
+
record = record_json
|
455
|
+
is_json = true
|
456
|
+
end
|
457
|
+
# Restore timestamp if necessary.
|
458
|
+
unless record.key?('time') || timestamp.nil?
|
459
|
+
record['time'] = timestamp
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
ts_secs, ts_nanos = compute_timestamp(record, time)
|
464
|
+
if @use_grpc
|
465
|
+
# If "seconds" is null or not an integer, we will omit the timestamp
|
466
|
+
# field and defer the decision on how to handle it to the downstream
|
467
|
+
# Logging API. If "nanos" is null or not an integer, it will be set
|
468
|
+
# to 0.
|
469
|
+
if ts_secs.is_a?(Integer)
|
470
|
+
ts_nanos = 0 unless ts_nanos.is_a?(Integer)
|
471
|
+
entry.metadata.timestamp = Google::Protobuf::Timestamp.new(
|
472
|
+
seconds: ts_secs,
|
473
|
+
nanos: ts_nanos
|
474
|
+
)
|
475
|
+
end
|
476
|
+
|
477
|
+
entry.metadata.severity =
|
478
|
+
grpc_severity(compute_severity(record, entry))
|
479
|
+
|
480
|
+
set_http_request_grpc(record, entry) # FIXME
|
481
|
+
else
|
482
|
+
entry.metadata.timestamp = {
|
483
|
+
seconds: ts_secs,
|
484
|
+
nanos: ts_nanos
|
485
|
+
}
|
486
|
+
|
487
|
+
entry.metadata.severity =
|
488
|
+
compute_severity(record, entry)
|
489
|
+
|
490
|
+
set_http_request(record, entry)
|
491
|
+
end
|
492
|
+
|
493
|
+
# If a field is present in the label_map, send its value as a label
|
494
|
+
# (mapping the field name to label name as specified in the config)
|
495
|
+
# and do not send that field as part of the payload.
|
496
|
+
if @label_map
|
497
|
+
@label_map.each do |field, label|
|
498
|
+
field_to_label(record, field, entry.metadata.labels, label)
|
499
|
+
end
|
500
|
+
end
|
501
|
+
|
502
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE &&
|
503
|
+
@cloudfunctions_log_match &&
|
504
|
+
@cloudfunctions_log_match['execution_id']
|
505
|
+
entry.metadata.labels['execution_id'] =
|
506
|
+
@cloudfunctions_log_match['execution_id']
|
507
|
+
end
|
508
|
+
|
509
|
+
if @use_grpc
|
510
|
+
set_payload_grpc(record, entry, is_json)
|
511
|
+
else
|
512
|
+
set_payload(record, entry, is_json)
|
513
|
+
entry.metadata.labels = nil if entry.metadata.labels.empty?
|
514
|
+
end
|
515
|
+
|
516
|
+
entries.push(entry)
|
517
|
+
end
|
518
|
+
# Don't send an empty request if we rejected all the entries.
|
519
|
+
next if entries.empty?
|
520
|
+
|
521
|
+
log_name = log_name(tag, labels)
|
522
|
+
|
523
|
+
if @use_grpc
|
524
|
+
begin
|
525
|
+
# Does the actual write to the cloud logging api.
|
526
|
+
|
527
|
+
client = api_client
|
528
|
+
|
529
|
+
labels_utf8_pairs = labels.map do |k, v|
|
530
|
+
[k.encode('utf-8'), convert_to_utf8(v)]
|
531
|
+
end
|
532
|
+
|
533
|
+
write_request = Google::Logging::V1::WriteLogEntriesRequest.new(
|
534
|
+
log_name: "projects/#{@project_id}/logs/#{log_name}",
|
535
|
+
common_labels: Hash[labels_utf8_pairs],
|
536
|
+
entries: entries
|
537
|
+
)
|
538
|
+
|
539
|
+
client.write_log_entries(write_request)
|
540
|
+
|
541
|
+
# Let the user explicitly know when the first call succeeded,
|
542
|
+
# to aid with verification and troubleshooting.
|
543
|
+
unless @successful_call
|
544
|
+
@successful_call = true
|
545
|
+
@log.info 'Successfully sent gRPC to Stackdriver Logging API.'
|
546
|
+
end
|
547
|
+
|
548
|
+
rescue GRPC::Cancelled => error
|
549
|
+
# RPC cancelled, so retry via re-raising the error.
|
550
|
+
raise error
|
551
|
+
|
552
|
+
rescue GRPC::BadStatus => error
|
553
|
+
case error.code
|
554
|
+
when GRPC::Core::StatusCodes::CANCELLED,
|
555
|
+
GRPC::Core::StatusCodes::UNAVAILABLE,
|
556
|
+
GRPC::Core::StatusCodes::DEADLINE_EXCEEDED,
|
557
|
+
GRPC::Core::StatusCodes::INTERNAL,
|
558
|
+
GRPC::Core::StatusCodes::UNKNOWN
|
559
|
+
# TODO
|
560
|
+
# Server error, so retry via re-raising the error.
|
561
|
+
raise error
|
562
|
+
when GRPC::Core::StatusCodes::UNIMPLEMENTED,
|
563
|
+
GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED
|
564
|
+
# Most client errors indicate a problem with the request itself
|
565
|
+
# and should not be retried.
|
566
|
+
dropped = entries.length
|
567
|
+
@log.warn "Dropping #{dropped} log message(s)",
|
568
|
+
error: error.to_s, error_code: error.code.to_s
|
569
|
+
when GRPC::Core::StatusCodes::UNAUTHENTICATED
|
570
|
+
# Authorization error.
|
571
|
+
# These are usually solved via a `gcloud auth` call, or by
|
572
|
+
# modifying the permissions on the Google Cloud project.
|
573
|
+
dropped = entries.length
|
574
|
+
@log.warn "Dropping #{dropped} log message(s)",
|
575
|
+
error: error.to_s, error_code: error.code.to_s
|
576
|
+
else
|
577
|
+
# Assume this is a problem with the request itself
|
578
|
+
# and don't retry.
|
579
|
+
dropped = entries.length
|
580
|
+
@log.error "Unknown response code #{error.code} from the "\
|
581
|
+
"server, dropping #{dropped} log message(s)",
|
582
|
+
error: error.to_s, error_code: error.code.to_s
|
583
|
+
end
|
584
|
+
end
|
585
|
+
else
|
586
|
+
begin
|
587
|
+
# Does the actual write to the cloud logging api.
|
588
|
+
|
589
|
+
client = api_client
|
590
|
+
|
591
|
+
# The URI of the write is constructed by the Google::Api request;
|
592
|
+
# it is equivalent to this URL:
|
593
|
+
# 'https://logging.googleapis.com/v1beta3/projects/' \
|
594
|
+
# "#{@project_id}/logs/#{log_name}/entries:write"
|
595
|
+
write_request = \
|
596
|
+
Google::Apis::LoggingV1beta3::WriteLogEntriesRequest.new(
|
597
|
+
common_labels: labels,
|
598
|
+
entries: entries)
|
599
|
+
|
600
|
+
# TODO: RequestOptions
|
601
|
+
client.write_log_entries(@project_id, log_name, write_request)
|
602
|
+
|
603
|
+
# Let the user explicitly know when the first call succeeded,
|
604
|
+
# to aid with verification and troubleshooting.
|
605
|
+
unless @successful_call
|
606
|
+
@successful_call = true
|
607
|
+
@log.info 'Successfully sent to Stackdriver Logging API.'
|
608
|
+
end
|
609
|
+
|
610
|
+
rescue Google::Apis::ServerError => error
|
611
|
+
# Server error, so retry via re-raising the error.
|
612
|
+
raise error
|
613
|
+
|
614
|
+
rescue Google::Apis::AuthorizationError => error
|
615
|
+
# Authorization error.
|
616
|
+
# These are usually solved via a `gcloud auth` call, or by modifying
|
617
|
+
# the permissions on the Google Cloud project.
|
618
|
+
dropped = entries.length
|
619
|
+
@log.warn "Dropping #{dropped} log message(s)",
|
620
|
+
error_class: error.class.to_s, error: error.to_s
|
621
|
+
|
622
|
+
rescue Google::Apis::ClientError => error
|
623
|
+
# Most ClientErrors indicate a problem with the request itself and
|
624
|
+
# should not be retried.
|
625
|
+
dropped = entries.length
|
626
|
+
@log.warn "Dropping #{dropped} log message(s)",
|
627
|
+
error_class: error.class.to_s, error: error.to_s
|
628
|
+
end
|
629
|
+
end
|
630
|
+
end
|
631
|
+
end
|
632
|
+
|
633
|
+
private
|
634
|
+
|
635
|
+
def parse_json_or_nil(input)
|
636
|
+
# Only here to please rubocop...
|
637
|
+
return nil if input.nil?
|
638
|
+
|
639
|
+
input.each_codepoint do |c|
|
640
|
+
if c == 123
|
641
|
+
# left curly bracket (U+007B)
|
642
|
+
begin
|
643
|
+
return JSON.parse(input)
|
644
|
+
rescue JSON::ParserError
|
645
|
+
return nil
|
646
|
+
end
|
647
|
+
else
|
648
|
+
# Break (and return nil) unless the current character is whitespace,
|
649
|
+
# in which case we continue to look for a left curly bracket.
|
650
|
+
# Whitespace as per the JSON spec are: tabulation (U+0009),
|
651
|
+
# line feed (U+000A), carriage return (U+000D), and space (U+0020).
|
652
|
+
break unless c == 9 || c == 10 || c == 13 || c == 32
|
653
|
+
end # case
|
654
|
+
end # do
|
655
|
+
nil
|
656
|
+
end
|
657
|
+
|
658
|
+
# "enum" of Platform values
|
659
|
+
module Platform
|
660
|
+
OTHER = 0 # Other/unkown platform
|
661
|
+
GCE = 1 # Google Compute Engine
|
662
|
+
EC2 = 2 # Amazon EC2
|
663
|
+
end
|
664
|
+
|
665
|
+
# Determine what platform we are running on by consulting the metadata
|
666
|
+
# service (unless the user has explicitly disabled using that).
|
667
|
+
def detect_platform
|
668
|
+
unless @use_metadata_service
|
669
|
+
@log.info 'use_metadata_service is false; not detecting platform'
|
670
|
+
return Platform::OTHER
|
671
|
+
end
|
672
|
+
|
673
|
+
begin
|
674
|
+
open('http://' + METADATA_SERVICE_ADDR) do |f|
|
675
|
+
if f.meta['metadata-flavor'] == 'Google'
|
676
|
+
@log.info 'Detected GCE platform'
|
677
|
+
return Platform::GCE
|
678
|
+
end
|
679
|
+
if f.meta['server'] == 'EC2ws'
|
680
|
+
@log.info 'Detected EC2 platform'
|
681
|
+
return Platform::EC2
|
682
|
+
end
|
683
|
+
end
|
684
|
+
rescue StandardError => e
|
685
|
+
@log.debug 'Failed to access metadata service: ', error: e
|
686
|
+
end
|
687
|
+
|
688
|
+
@log.info 'Unable to determine platform'
|
689
|
+
Platform::OTHER
|
690
|
+
end
|
691
|
+
|
692
|
+
def fetch_gce_metadata(metadata_path)
|
693
|
+
fail "Called fetch_gce_metadata with platform=#{@platform}" unless
|
694
|
+
@platform == Platform::GCE
|
695
|
+
# See https://cloud.google.com/compute/docs/metadata
|
696
|
+
open('http://' + METADATA_SERVICE_ADDR + '/computeMetadata/v1/' +
|
697
|
+
metadata_path, 'Metadata-Flavor' => 'Google', &:read)
|
698
|
+
end
|
699
|
+
|
700
|
+
def fetch_ec2_metadata
|
701
|
+
fail "Called fetch_ec2_metadata with platform=#{@platform}" unless
|
702
|
+
@platform == Platform::EC2
|
703
|
+
# See http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
|
704
|
+
open('http://' + METADATA_SERVICE_ADDR +
|
705
|
+
'/latest/dynamic/instance-identity/document') do |f|
|
706
|
+
contents = f.read
|
707
|
+
return JSON.parse(contents)
|
708
|
+
end
|
709
|
+
end
|
710
|
+
|
711
|
+
# TODO: This functionality should eventually be available in another
|
712
|
+
# library, but implement it ourselves for now.
|
713
|
+
module CredentialsInfo
|
714
|
+
# Determine the project ID from the credentials, if possible.
|
715
|
+
# Returns the project ID (as a string) on success, or nil on failure.
|
716
|
+
def self.project_id
|
717
|
+
creds = Google::Auth.get_application_default(LOGGING_SCOPE)
|
718
|
+
if creds.issuer
|
719
|
+
id = extract_project_id(creds.issuer)
|
720
|
+
return id unless id.nil?
|
721
|
+
end
|
722
|
+
if creds.client_id
|
723
|
+
id = extract_project_id(creds.client_id)
|
724
|
+
return id unless id.nil?
|
725
|
+
end
|
726
|
+
nil
|
727
|
+
end
|
728
|
+
|
729
|
+
# Extracts the project id (either name or number) from str and returns
|
730
|
+
# it (as a string) on success, or nil on failure.
|
731
|
+
#
|
732
|
+
# Recognizes IAM format (account@project-name.iam.gserviceaccount.com)
|
733
|
+
# as well as the legacy format with a project number at the front of the
|
734
|
+
# string, terminated by a dash (-) which is not part of the ID, i.e.:
|
735
|
+
# 270694816269-1l1r2hb813leuppurdeik0apglbs80sv.apps.googleusercontent.com
|
736
|
+
def self.extract_project_id(str)
|
737
|
+
[/^.*@(?<project_id>.+)\.iam\.gserviceaccount\.com/,
|
738
|
+
/^(?<project_id>\d+)-/].each do |exp|
|
739
|
+
match_data = exp.match(str)
|
740
|
+
return match_data['project_id'] unless match_data.nil?
|
741
|
+
end
|
742
|
+
nil
|
743
|
+
end
|
744
|
+
end
|
745
|
+
|
746
|
+
def detect_cloudfunctions(attributes)
|
747
|
+
return unless attributes.include?('gcf_region')
|
748
|
+
# Cloud Functions detected
|
749
|
+
@running_cloudfunctions = true
|
750
|
+
@gcf_region = fetch_gce_metadata('instance/attributes/gcf_region')
|
751
|
+
end
|
752
|
+
|
753
|
+
def cluster_name_from_kube_env(kube_env)
|
754
|
+
return kube_env['CLUSTER_NAME'] if kube_env.key?('CLUSTER_NAME')
|
755
|
+
instance_prefix = kube_env['INSTANCE_PREFIX']
|
756
|
+
gke_name_match = /^gke-(.+)-[0-9a-f]{8}$/.match(instance_prefix)
|
757
|
+
return gke_name_match.captures[0] if gke_name_match &&
|
758
|
+
!gke_name_match.captures.empty?
|
759
|
+
instance_prefix
|
760
|
+
end
|
761
|
+
|
762
|
+
def compute_timestamp(record, time)
|
763
|
+
if record.key?('timestamp') &&
|
764
|
+
record['timestamp'].is_a?(Hash) &&
|
765
|
+
record['timestamp'].key?('seconds') &&
|
766
|
+
record['timestamp'].key?('nanos')
|
767
|
+
ts_secs = record['timestamp']['seconds']
|
768
|
+
ts_nanos = record['timestamp']['nanos']
|
769
|
+
record.delete('timestamp')
|
770
|
+
elsif record.key?('timestampSeconds') &&
|
771
|
+
record.key?('timestampNanos')
|
772
|
+
ts_secs = record.delete('timestampSeconds')
|
773
|
+
ts_nanos = record.delete('timestampNanos')
|
774
|
+
elsif record.key?('timeNanos')
|
775
|
+
# This is deprecated since the precision is insufficient.
|
776
|
+
# Use timestampSeconds/timestampNanos instead
|
777
|
+
nanos = record.delete('timeNanos')
|
778
|
+
ts_secs = (nanos / 1_000_000_000).to_i
|
779
|
+
ts_nanos = nanos % 1_000_000_000
|
780
|
+
unless @timenanos_warning
|
781
|
+
# Warn the user this is deprecated, but only once to avoid spam.
|
782
|
+
@timenanos_warning = true
|
783
|
+
@log.warn 'timeNanos is deprecated - please use ' \
|
784
|
+
'timestampSeconds and timestampNanos instead.'
|
785
|
+
end
|
786
|
+
elsif @service_name == CLOUDFUNCTIONS_SERVICE &&
|
787
|
+
@cloudfunctions_log_match
|
788
|
+
timestamp = DateTime.parse(@cloudfunctions_log_match['timestamp'])
|
789
|
+
ts_secs = timestamp.strftime('%s').to_i
|
790
|
+
ts_nanos = timestamp.strftime('%N').to_i
|
791
|
+
elsif record.key?('time')
|
792
|
+
# k8s ISO8601 timestamp
|
793
|
+
begin
|
794
|
+
timestamp = Time.iso8601(record.delete('time'))
|
795
|
+
rescue
|
796
|
+
timestamp = Time.at(time)
|
797
|
+
end
|
798
|
+
ts_secs = timestamp.tv_sec
|
799
|
+
ts_nanos = timestamp.tv_nsec
|
800
|
+
else
|
801
|
+
timestamp = Time.at(time)
|
802
|
+
ts_secs = timestamp.tv_sec
|
803
|
+
ts_nanos = timestamp.tv_nsec
|
804
|
+
end
|
805
|
+
[ts_secs, ts_nanos]
|
806
|
+
end
|
807
|
+
|
808
|
+
def compute_severity(record, entry)
|
809
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE
|
810
|
+
if @cloudfunctions_log_match && @cloudfunctions_log_match['severity']
|
811
|
+
return parse_severity(@cloudfunctions_log_match['severity'])
|
812
|
+
elsif record.key?('stream') && record['stream'] == 'stdout'
|
813
|
+
record.delete('stream')
|
814
|
+
return 'INFO'
|
815
|
+
elsif record.key?('stream') && record['stream'] == 'stderr'
|
816
|
+
record.delete('stream')
|
817
|
+
return 'ERROR'
|
818
|
+
else
|
819
|
+
return 'DEFAULT'
|
820
|
+
end
|
821
|
+
elsif record.key?('severity')
|
822
|
+
return parse_severity(record.delete('severity'))
|
823
|
+
elsif @service_name == CONTAINER_SERVICE && \
|
824
|
+
entry.metadata.labels.key?("#{CONTAINER_SERVICE}/stream")
|
825
|
+
stream = entry.metadata.labels["#{CONTAINER_SERVICE}/stream"]
|
826
|
+
if stream == 'stdout'
|
827
|
+
return 'INFO'
|
828
|
+
elsif stream == 'stderr'
|
829
|
+
return 'ERROR'
|
830
|
+
else
|
831
|
+
return 'DEFAULT'
|
832
|
+
end
|
833
|
+
else
|
834
|
+
return 'DEFAULT'
|
835
|
+
end
|
836
|
+
end
|
837
|
+
|
838
|
+
def set_http_request(record, entry)
|
839
|
+
return nil unless record['httpRequest'].is_a?(Hash)
|
840
|
+
input = record['httpRequest']
|
841
|
+
output = Google::Apis::LoggingV1beta3::HttpRequest.new
|
842
|
+
output.request_method = input.delete('requestMethod')
|
843
|
+
output.request_url = input.delete('requestUrl')
|
844
|
+
output.request_size = input.delete('requestSize')
|
845
|
+
output.status = input.delete('status')
|
846
|
+
output.response_size = input.delete('responseSize')
|
847
|
+
output.user_agent = input.delete('userAgent')
|
848
|
+
output.remote_ip = input.delete('remoteIp')
|
849
|
+
output.referer = input.delete('referer')
|
850
|
+
output.cache_hit = input.delete('cacheHit')
|
851
|
+
output.validated_with_origin_server = \
|
852
|
+
input.delete('validatedWithOriginServer')
|
853
|
+
record.delete('httpRequest') if input.empty?
|
854
|
+
entry.http_request = output
|
855
|
+
end
|
856
|
+
|
857
|
+
def set_http_request_grpc(record, entry)
|
858
|
+
return nil unless record['httpRequest'].is_a?(Hash)
|
859
|
+
input = record['httpRequest']
|
860
|
+
output = Google::Logging::Type::HttpRequest.new
|
861
|
+
# We need to delete each field from 'httpRequest' even if its value is
|
862
|
+
# nil. However we do not want to assign this nil value to proto fields
|
863
|
+
# defined as strings / integers.
|
864
|
+
request_method = input.delete('requestMethod')
|
865
|
+
output.request_method = request_method unless request_method.nil?
|
866
|
+
request_url = input.delete('requestUrl')
|
867
|
+
output.request_url = request_url unless request_url.nil?
|
868
|
+
request_size = input.delete('requestSize')
|
869
|
+
output.request_size = request_size.to_i unless request_size.nil?
|
870
|
+
status = input.delete('status')
|
871
|
+
output.status = status.to_i unless status.nil?
|
872
|
+
response_size = input.delete('responseSize')
|
873
|
+
output.response_size = response_size.to_i unless response_size.nil?
|
874
|
+
user_agent = input.delete('userAgent')
|
875
|
+
output.user_agent = user_agent unless user_agent.nil?
|
876
|
+
remote_ip = input.delete('remoteIp')
|
877
|
+
output.remote_ip = remote_ip unless remote_ip.nil?
|
878
|
+
referer = input.delete('referer')
|
879
|
+
output.referer = referer unless referer.nil?
|
880
|
+
cache_hit = input.delete('cacheHit')
|
881
|
+
output.cache_hit = cache_hit unless cache_hit.nil?
|
882
|
+
cache_validated_with_origin_server = \
|
883
|
+
input.delete('cacheValidatedWithOriginServer')
|
884
|
+
output.cache_validated_with_origin_server = \
|
885
|
+
cache_validated_with_origin_server \
|
886
|
+
unless cache_validated_with_origin_server.nil?
|
887
|
+
record.delete('httpRequest') if input.empty?
|
888
|
+
entry.http_request = output
|
889
|
+
end
|
890
|
+
|
891
|
+
# Values permitted by the API for 'severity' (which is an enum).
|
892
|
+
VALID_SEVERITIES = Set.new(
|
893
|
+
%w(DEFAULT DEBUG INFO NOTICE WARNING ERROR CRITICAL ALERT EMERGENCY))
|
894
|
+
|
895
|
+
# Translates other severity strings to one of the valid values above.
|
896
|
+
SEVERITY_TRANSLATIONS = {
|
897
|
+
# log4j levels (both current and obsolete).
|
898
|
+
'WARN' => 'WARNING',
|
899
|
+
'FATAL' => 'CRITICAL',
|
900
|
+
'TRACE' => 'DEBUG',
|
901
|
+
'TRACE_INT' => 'DEBUG',
|
902
|
+
'FINE' => 'DEBUG',
|
903
|
+
'FINER' => 'DEBUG',
|
904
|
+
'FINEST' => 'DEBUG',
|
905
|
+
# nginx levels (only missing ones from above listed).
|
906
|
+
'CRIT' => 'CRITICAL',
|
907
|
+
'EMERG' => 'EMERGENCY',
|
908
|
+
# single-letter levels. Note E->ERROR and D->DEBUG.
|
909
|
+
'D' => 'DEBUG',
|
910
|
+
'I' => 'INFO',
|
911
|
+
'N' => 'NOTICE',
|
912
|
+
'W' => 'WARNING',
|
913
|
+
'E' => 'ERROR',
|
914
|
+
'C' => 'CRITICAL',
|
915
|
+
'A' => 'ALERT',
|
916
|
+
# other misc. translations.
|
917
|
+
'ERR' => 'ERROR',
|
918
|
+
'F' => 'CRITICAL'
|
919
|
+
}
|
920
|
+
|
921
|
+
def parse_severity(severity_str)
|
922
|
+
# The API is case insensitive, but uppercase to make things simpler.
|
923
|
+
severity = severity_str.upcase.strip
|
924
|
+
|
925
|
+
# If the severity is already valid, just return it.
|
926
|
+
return severity if VALID_SEVERITIES.include?(severity)
|
927
|
+
|
928
|
+
# If the severity is an integer (string) return it as an integer,
|
929
|
+
# truncated to the closest valid value (multiples of 100 between 0-800).
|
930
|
+
if /\A\d+\z/.match(severity)
|
931
|
+
begin
|
932
|
+
numeric_severity = (severity.to_i / 100) * 100
|
933
|
+
if numeric_severity < 0
|
934
|
+
return 0
|
935
|
+
elsif numeric_severity > 800
|
936
|
+
return 800
|
937
|
+
else
|
938
|
+
return numeric_severity
|
939
|
+
end
|
940
|
+
rescue
|
941
|
+
return 'DEFAULT'
|
942
|
+
end
|
943
|
+
end
|
944
|
+
|
945
|
+
# Try to translate the severity.
|
946
|
+
if SEVERITY_TRANSLATIONS.key?(severity)
|
947
|
+
return SEVERITY_TRANSLATIONS[severity]
|
948
|
+
end
|
949
|
+
|
950
|
+
# If all else fails, use 'DEFAULT'.
|
951
|
+
'DEFAULT'
|
952
|
+
end
|
953
|
+
|
954
|
+
GRPC_SEVERITY_MAPPING = {
|
955
|
+
'DEFAULT' => Google::Logging::Type::LogSeverity::DEFAULT,
|
956
|
+
'DEBUG' => Google::Logging::Type::LogSeverity::DEBUG,
|
957
|
+
'INFO' => Google::Logging::Type::LogSeverity::INFO,
|
958
|
+
'NOTICE' => Google::Logging::Type::LogSeverity::NOTICE,
|
959
|
+
'WARNING' => Google::Logging::Type::LogSeverity::WARNING,
|
960
|
+
'ERROR' => Google::Logging::Type::LogSeverity::ERROR,
|
961
|
+
'CRITICAL' => Google::Logging::Type::LogSeverity::CRITICAL,
|
962
|
+
'ALERT' => Google::Logging::Type::LogSeverity::ALERT,
|
963
|
+
'EMERGENCY' => Google::Logging::Type::LogSeverity::EMERGENCY,
|
964
|
+
0 => Google::Logging::Type::LogSeverity::DEFAULT,
|
965
|
+
100 => Google::Logging::Type::LogSeverity::DEBUG,
|
966
|
+
200 => Google::Logging::Type::LogSeverity::INFO,
|
967
|
+
300 => Google::Logging::Type::LogSeverity::NOTICE,
|
968
|
+
400 => Google::Logging::Type::LogSeverity::WARNING,
|
969
|
+
500 => Google::Logging::Type::LogSeverity::ERROR,
|
970
|
+
600 => Google::Logging::Type::LogSeverity::CRITICAL,
|
971
|
+
700 => Google::Logging::Type::LogSeverity::ALERT,
|
972
|
+
800 => Google::Logging::Type::LogSeverity::EMERGENCY
|
973
|
+
}
|
974
|
+
|
975
|
+
def grpc_severity(severity)
|
976
|
+
# TODO: find out why this doesn't work.
|
977
|
+
# if severity.is_a? String
|
978
|
+
# return Google::Logging::Type::LogSeverity.resolve(severity)
|
979
|
+
# end
|
980
|
+
if GRPC_SEVERITY_MAPPING.key?(severity)
|
981
|
+
return GRPC_SEVERITY_MAPPING[severity]
|
982
|
+
end
|
983
|
+
severity
|
984
|
+
end
|
985
|
+
|
986
|
+
def decode_cloudfunctions_function_name(function_name)
|
987
|
+
function_name.gsub(/c\.[a-z]/) { |s| s.upcase[-1] }
|
988
|
+
.gsub('u.u', '_').gsub('d.d', '$').gsub('a.a', '@').gsub('p.p', '.')
|
989
|
+
end
|
990
|
+
|
991
|
+
# Requires that record has a 'kubernetes' field.
|
992
|
+
def handle_container_metadata(record, entry)
|
993
|
+
fields = %w(namespace_id namespace_name pod_id pod_name container_name)
|
994
|
+
fields.each do |field|
|
995
|
+
field_to_label(record['kubernetes'], field, entry.metadata.labels,
|
996
|
+
"#{CONTAINER_SERVICE}/#{field}")
|
997
|
+
end
|
998
|
+
# Prepend label/ to all user-defined labels' keys.
|
999
|
+
if record['kubernetes'].key?('labels')
|
1000
|
+
record['kubernetes']['labels'].each do |key, value|
|
1001
|
+
entry.metadata.labels["label/#{key}"] = value
|
1002
|
+
end
|
1003
|
+
end
|
1004
|
+
# We've explicitly consumed all the fields we care about -- don't litter
|
1005
|
+
# the log entries with the remaining fields that the kubernetes metadata
|
1006
|
+
# filter plugin includes (or an empty 'kubernetes' field).
|
1007
|
+
record.delete('kubernetes')
|
1008
|
+
record.delete('docker')
|
1009
|
+
end
|
1010
|
+
|
1011
|
+
def field_to_label(record, field, labels, label)
|
1012
|
+
return unless record.key?(field)
|
1013
|
+
labels[label] = convert_to_utf8(record[field].to_s)
|
1014
|
+
record.delete(field)
|
1015
|
+
end
|
1016
|
+
|
1017
|
+
def set_payload(record, entry, is_json)
|
1018
|
+
# If this is a Cloud Functions log that matched the expected regexp,
|
1019
|
+
# use text payload. Otherwise, use JSON if we found valid JSON, or text
|
1020
|
+
# payload in the following cases:
|
1021
|
+
# 1. This is a Cloud Functions log and the 'log' key is available
|
1022
|
+
# 2. This is an unstructured Container log and the 'log' key is available
|
1023
|
+
# 3. The only remaining key is 'message'
|
1024
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE && @cloudfunctions_log_match
|
1025
|
+
entry.text_payload = @cloudfunctions_log_match['text']
|
1026
|
+
elsif @service_name == CLOUDFUNCTIONS_SERVICE && record.key?('log')
|
1027
|
+
entry.text_payload = record['log']
|
1028
|
+
elsif is_json
|
1029
|
+
entry.struct_payload = record
|
1030
|
+
elsif @service_name == CONTAINER_SERVICE && record.key?('log')
|
1031
|
+
entry.text_payload = record['log']
|
1032
|
+
elsif record.size == 1 && record.key?('message')
|
1033
|
+
entry.text_payload = record['message']
|
1034
|
+
else
|
1035
|
+
entry.struct_payload = record
|
1036
|
+
end
|
1037
|
+
end
|
1038
|
+
|
1039
|
+
def value_from_ruby(value)
|
1040
|
+
ret = Google::Protobuf::Value.new
|
1041
|
+
case value
|
1042
|
+
when NilClass
|
1043
|
+
ret.null_value = 0
|
1044
|
+
when Numeric
|
1045
|
+
ret.number_value = value
|
1046
|
+
when String
|
1047
|
+
ret.string_value = convert_to_utf8(value)
|
1048
|
+
when TrueClass
|
1049
|
+
ret.bool_value = true
|
1050
|
+
when FalseClass
|
1051
|
+
ret.bool_value = false
|
1052
|
+
when Google::Protobuf::Struct
|
1053
|
+
ret.struct_value = value
|
1054
|
+
when Hash
|
1055
|
+
ret.struct_value = struct_from_ruby(value)
|
1056
|
+
when Google::Protobuf::ListValue
|
1057
|
+
ret.list_value = value
|
1058
|
+
when Array
|
1059
|
+
ret.list_value = list_from_ruby(value)
|
1060
|
+
else
|
1061
|
+
@log.error "Unknown type: #{value.class}"
|
1062
|
+
fail Google::Protobuf::Error, "Unknown type: #{value.class}"
|
1063
|
+
end
|
1064
|
+
ret
|
1065
|
+
end
|
1066
|
+
|
1067
|
+
def list_from_ruby(arr)
|
1068
|
+
ret = Google::Protobuf::ListValue.new
|
1069
|
+
arr.each do |v|
|
1070
|
+
ret.values << value_from_ruby(v)
|
1071
|
+
end
|
1072
|
+
ret
|
1073
|
+
end
|
1074
|
+
|
1075
|
+
def struct_from_ruby(hash)
|
1076
|
+
ret = Google::Protobuf::Struct.new
|
1077
|
+
hash.each do |k, v|
|
1078
|
+
ret.fields[convert_to_utf8(k.to_s)] ||= value_from_ruby(v)
|
1079
|
+
end
|
1080
|
+
ret
|
1081
|
+
end
|
1082
|
+
|
1083
|
+
def set_payload_grpc(record, entry, is_json)
|
1084
|
+
# If this is a Cloud Functions log that matched the expected regexp,
|
1085
|
+
# use text payload. Otherwise, use JSON if we found valid JSON, or text
|
1086
|
+
# payload in the following cases:
|
1087
|
+
# 1. This is a Cloud Functions log and the 'log' key is available
|
1088
|
+
# 2. This is an unstructured Container log and the 'log' key is available
|
1089
|
+
# 3. The only remaining key is 'message'
|
1090
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE && @cloudfunctions_log_match
|
1091
|
+
entry.text_payload = convert_to_utf8(
|
1092
|
+
@cloudfunctions_log_match['text'])
|
1093
|
+
elsif @service_name == CLOUDFUNCTIONS_SERVICE && record.key?('log')
|
1094
|
+
entry.text_payload = convert_to_utf8(record['log'])
|
1095
|
+
elsif is_json
|
1096
|
+
entry.struct_payload = struct_from_ruby(record)
|
1097
|
+
elsif @service_name == CONTAINER_SERVICE && record.key?('log')
|
1098
|
+
entry.text_payload = convert_to_utf8(record['log'])
|
1099
|
+
elsif record.size == 1 && record.key?('message')
|
1100
|
+
entry.text_payload = convert_to_utf8(record['message'])
|
1101
|
+
else
|
1102
|
+
entry.struct_payload = struct_from_ruby(record)
|
1103
|
+
end
|
1104
|
+
end
|
1105
|
+
|
1106
|
+
def log_name(tag, common_labels)
|
1107
|
+
if @service_name == CLOUDFUNCTIONS_SERVICE
|
1108
|
+
tag = 'cloud-functions'
|
1109
|
+
elsif @running_on_managed_vm
|
1110
|
+
# Add a prefix to Managed VM logs to prevent namespace collisions.
|
1111
|
+
tag = "#{APPENGINE_SERVICE}/#{tag}"
|
1112
|
+
elsif @service_name == CONTAINER_SERVICE
|
1113
|
+
# For Kubernetes logs, use just the container name as the log name
|
1114
|
+
# if we have it.
|
1115
|
+
container_name_key = "#{CONTAINER_SERVICE}/container_name"
|
1116
|
+
if common_labels && common_labels.key?(container_name_key)
|
1117
|
+
sanitized_log_name = sanitize_tag(common_labels[container_name_key])
|
1118
|
+
tag = sanitized_log_name unless sanitized_log_name.nil?
|
1119
|
+
end
|
1120
|
+
end
|
1121
|
+
# Only encode the log name for the grpc path, since the non-grpc client
|
1122
|
+
# lib already handles encoding.
|
1123
|
+
tag = ERB::Util.url_encode(tag) if @use_grpc
|
1124
|
+
tag
|
1125
|
+
end
|
1126
|
+
|
1127
|
+
def init_api_client
|
1128
|
+
return if @use_grpc
|
1129
|
+
# TODO: Use a non-default ClientOptions object.
|
1130
|
+
Google::Apis::ClientOptions.default.application_name = PLUGIN_NAME
|
1131
|
+
Google::Apis::ClientOptions.default.application_version = PLUGIN_VERSION
|
1132
|
+
@client = Google::Apis::LoggingV1beta3::LoggingService.new
|
1133
|
+
@client.authorization = Google::Auth.get_application_default(
|
1134
|
+
LOGGING_SCOPE)
|
1135
|
+
end
|
1136
|
+
|
1137
|
+
def api_client
|
1138
|
+
if @use_grpc
|
1139
|
+
ssl_creds = GRPC::Core::ChannelCredentials.new
|
1140
|
+
authentication = Google::Auth.get_application_default
|
1141
|
+
creds = GRPC::Core::CallCredentials.new(authentication.updater_proc)
|
1142
|
+
creds = ssl_creds.compose(creds)
|
1143
|
+
@client = Google::Logging::V1::LoggingService::Stub.new(
|
1144
|
+
'logging.googleapis.com', creds)
|
1145
|
+
else
|
1146
|
+
unless @client.authorization.expired?
|
1147
|
+
begin
|
1148
|
+
@client.authorization.fetch_access_token!
|
1149
|
+
rescue MultiJson::ParseError
|
1150
|
+
# Workaround an issue in the API client; just re-raise a more
|
1151
|
+
# descriptive error for the user (which will still cause a retry).
|
1152
|
+
raise Google::APIClient::ClientError, 'Unable to fetch access ' \
|
1153
|
+
'token (no scopes configured?)'
|
1154
|
+
end
|
1155
|
+
end
|
1156
|
+
end
|
1157
|
+
@client
|
1158
|
+
end
|
1159
|
+
|
1160
|
+
# Encode as UTF-8. If 'coerce_to_utf8' is set to true in the config, any
|
1161
|
+
# non-UTF-8 character would be replaced by the string specified by
|
1162
|
+
# 'non_utf8_replacement_string'. If 'coerce_to_utf8' is set to false, any
|
1163
|
+
# non-UTF-8 character would trigger the plugin to error out.
|
1164
|
+
def convert_to_utf8(input)
|
1165
|
+
if @coerce_to_utf8
|
1166
|
+
input.encode(
|
1167
|
+
'utf-8',
|
1168
|
+
invalid: :replace,
|
1169
|
+
undef: :replace,
|
1170
|
+
replace: @non_utf8_replacement_string)
|
1171
|
+
else
|
1172
|
+
begin
|
1173
|
+
input.encode('utf-8')
|
1174
|
+
rescue EncodingError
|
1175
|
+
@log.error 'Encountered encoding issues potentially due to non ' \
|
1176
|
+
'UTF-8 characters. To allow non-UTF-8 characters and ' \
|
1177
|
+
'replace them with spaces, please set "coerce_to_utf8" ' \
|
1178
|
+
'to true.'
|
1179
|
+
raise
|
1180
|
+
end
|
1181
|
+
end
|
1182
|
+
end
|
1183
|
+
end
|
1184
|
+
end
|