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