vmik-fluent-plugin-google-cloud 0.5.5 → 0.6.4.pre.alpha

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.
@@ -18,12 +18,14 @@ require 'socket'
18
18
  require 'time'
19
19
  require 'yaml'
20
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'
21
+ require 'google/apis/logging_v2beta1'
22
+ require 'google/logging/v2/logging_pb'
23
+ require 'google/logging/v2/logging_services_pb'
24
+ require 'google/logging/v2/log_entry_pb'
25
25
  require 'googleauth'
26
26
 
27
+ require_relative 'monitoring'
28
+
27
29
  module Google
28
30
  module Protobuf
29
31
  # Alias the has_key? method to have the same interface as a regular map.
@@ -36,17 +38,48 @@ end
36
38
  module Fluent
37
39
  # fluentd output plugin for the Stackdriver Logging API
38
40
  class GoogleCloudOutput < BufferedOutput
41
+ # Constants for service names and resource types.
42
+ module Constants
43
+ APPENGINE_CONSTANTS = {
44
+ service: 'appengine.googleapis.com',
45
+ resource_type: 'gae_app'
46
+ }
47
+ CLOUDFUNCTIONS_CONSTANTS = {
48
+ service: 'cloudfunctions.googleapis.com',
49
+ resource_type: 'cloud_function'
50
+ }
51
+ COMPUTE_CONSTANTS = {
52
+ service: 'compute.googleapis.com',
53
+ resource_type: 'gce_instance'
54
+ }
55
+ CONTAINER_CONSTANTS = {
56
+ service: 'container.googleapis.com',
57
+ resource_type: 'container'
58
+ }
59
+ DATAFLOW_CONSTANTS = {
60
+ service: 'dataflow.googleapis.com',
61
+ resource_type: 'dataflow_step'
62
+ }
63
+ DATAPROC_CONSTANTS = {
64
+ service: 'cluster.dataproc.googleapis.com',
65
+ resource_type: 'cloud_dataproc_cluster'
66
+ }
67
+ EC2_CONSTANTS = {
68
+ service: 'ec2.amazonaws.com',
69
+ resource_type: 'aws_ec2_instance'
70
+ }
71
+ ML_CONSTANTS = {
72
+ service: 'ml.googleapis.com',
73
+ resource_type: 'ml_job'
74
+ }
75
+ end
76
+
77
+ include self::Constants
78
+
39
79
  Fluent::Plugin.register_output('google_cloud', self)
40
80
 
41
81
  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'
82
+ PLUGIN_VERSION = '0.6.3'
50
83
 
51
84
  # Name of the the Google cloud logging write scope.
52
85
  LOGGING_SCOPE = 'https://www.googleapis.com/auth/logging.write'
@@ -155,6 +188,18 @@ module Fluent
155
188
  :default => nil,
156
189
  :secret => true
157
190
 
191
+ # Whether to collect metrics about the plugin usage. Use configuration
192
+ # parameter monitoring_type to select a monitoring system you want to use.
193
+ config_param :monitoring_enabled, :bool, :default => false
194
+
195
+ # What system to use when collecting metrics. Possible values are:
196
+ # - 'prometheus', in this case default registry in the Prometheus
197
+ # client library is used, without actually exposing the endpoint
198
+ # to serve metrics in the Prometheus format.
199
+ # - any other value will result in the absence of metrics.
200
+ config_param :monitoring_type, :string,
201
+ :default => Monitoring::PrometheusMonitoringRegistry.name
202
+
158
203
  # rubocop:enable Style/HashSyntax
159
204
 
160
205
  # TODO: Add a log_name config option rather than just using the tag?
@@ -167,7 +212,7 @@ module Fluent
167
212
  attr_reader :running_on_managed_vm
168
213
  attr_reader :gae_backend_name
169
214
  attr_reader :gae_backend_version
170
- attr_reader :service_name
215
+ attr_reader :resource
171
216
  attr_reader :common_labels
172
217
 
173
218
  def initialize
@@ -179,6 +224,25 @@ module Fluent
179
224
  def configure(conf)
180
225
  super
181
226
 
227
+ # If monitoring is enabled, register metrics in the default registry
228
+ # and store metric objects for future use.
229
+ if @monitoring_enabled
230
+ registry = Monitoring::MonitoringRegistryFactory.create @monitoring_type
231
+ @successful_requests_count = registry.counter(
232
+ :stackdriver_successful_requests_count,
233
+ 'A number of successful requests to the Stackdriver Logging API')
234
+ @failed_requests_count = registry.counter(
235
+ :stackdriver_failed_requests_count,
236
+ 'A number of failed requests to the Stackdriver Logging API,'\
237
+ ' broken down by the error code')
238
+ @ingested_entries_count = registry.counter(
239
+ :stackdriver_ingested_entries_count,
240
+ 'A number of log entries ingested by Stackdriver Logging')
241
+ @dropped_entries_count = registry.counter(
242
+ :stackdriver_dropped_entries_count,
243
+ 'A number of log entries dropped by the Stackdriver output plugin')
244
+ end
245
+
182
246
  # Alert on old authentication configuration.
183
247
  unless @auth_method.nil? && @private_key_email.nil? &&
184
248
  @private_key_path.nil? && @private_key_passphrase.nil?
@@ -198,6 +262,11 @@ module Fluent
198
262
  @common_labels = {}
199
263
  @common_labels.merge!(@labels) if @labels
200
264
 
265
+ # TODO: Construct Google::Api::MonitoredResource when @use_grpc is
266
+ # true after the protobuf map corruption issue is fixed.
267
+ @resource = Google::Apis::LoggingV2beta1::MonitoredResource.new(
268
+ labels: {})
269
+
201
270
  @compiled_kubernetes_tag_regexp = nil
202
271
  if @kubernetes_tag_regexp
203
272
  @compiled_kubernetes_tag_regexp = Regexp.new(@kubernetes_tag_regexp)
@@ -211,6 +280,8 @@ module Fluent
211
280
  (?:\[(?<execution_id>[^\]]+)\])?
212
281
  [ ](?<text>.*)$/x
213
282
 
283
+ @http_latency_regexp = /^\s*(?<seconds>\d+)(?<decimal>\.\d+)?\s*s\s*$/
284
+
214
285
  # set attributes from metadata (unless overriden by static config)
215
286
  @vm_name = Socket.gethostname if @vm_name.nil?
216
287
  @platform = detect_platform
@@ -235,7 +306,7 @@ module Fluent
235
306
  @vm_id = metadata['instanceId']
236
307
  end
237
308
  if metadata.key?('accountId')
238
- common_labels["#{EC2_SERVICE}/account_id"] = metadata['accountId']
309
+ @resource.labels['aws_account'] = metadata['accountId']
239
310
  end
240
311
  when Platform::OTHER
241
312
  # do nothing
@@ -268,12 +339,19 @@ module Fluent
268
339
  # Functions.
269
340
  @running_cloudfunctions = false
270
341
 
271
- # Set labels, etc. based on the config
342
+ # Set up the MonitoredResource, labels, etc. based on the config.
272
343
  case @platform
273
344
  when Platform::GCE
274
- @service_name = COMPUTE_SERVICE
345
+ @resource.type = COMPUTE_CONSTANTS[:resource_type]
346
+ # TODO: introduce a new MonitoredResource-centric configuration and
347
+ # deprecate subservice-name; for now, translate known uses.
275
348
  if @subservice_name
276
- @service_name = @subservice_name
349
+ # TODO: what should we do if we encounter an unknown value?
350
+ if @subservice_name == DATAFLOW_CONSTANTS[:service]
351
+ @resource.type = DATAFLOW_CONSTANTS[:resource_type]
352
+ elsif @subservice_name == ML_CONSTANTS[:service]
353
+ @resource.type = ML_CONSTANTS[:resource_type]
354
+ end
277
355
  elsif @detect_subservice
278
356
  # Check for specialized GCE environments.
279
357
  # TODO: Add config options for these to allow for running outside GCE?
@@ -287,41 +365,66 @@ module Fluent
287
365
  fetch_gce_metadata('instance/attributes/gae_backend_name')
288
366
  @gae_backend_version =
289
367
  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
368
+ @resource.type = APPENGINE_CONSTANTS[:resource_type]
369
+ @resource.labels['module_id'] = @gae_backend_name
370
+ @resource.labels['version_id'] = @gae_backend_version
294
371
  elsif attributes.include?('kube-env')
295
372
  # Kubernetes/Container Engine
296
- @service_name = CONTAINER_SERVICE
297
- common_labels["#{CONTAINER_SERVICE}/instance_id"] = @vm_id
373
+ @resource.type = CONTAINER_CONSTANTS[:resource_type]
298
374
  @raw_kube_env = fetch_gce_metadata('instance/attributes/kube-env')
299
375
  @kube_env = YAML.load(@raw_kube_env)
300
- common_labels["#{CONTAINER_SERVICE}/cluster_name"] =
376
+ @resource.labels['cluster_name'] =
301
377
  cluster_name_from_kube_env(@kube_env)
302
378
  detect_cloudfunctions(attributes)
379
+ elsif attributes.include?('dataproc-cluster-uuid') &&
380
+ attributes.include?('dataproc-cluster-name')
381
+ # Dataproc
382
+ @resource.type = DATAPROC_CONSTANTS[:resource_type]
383
+ @resource.labels['cluster_uuid'] =
384
+ fetch_gce_metadata('instance/attributes/dataproc-cluster-uuid')
385
+ @resource.labels['cluster_name'] =
386
+ fetch_gce_metadata('instance/attributes/dataproc-cluster-name')
387
+ @resource.labels['region'] =
388
+ fetch_gce_metadata('instance/attributes/dataproc-region')
303
389
  end
304
390
  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
391
+ # Some services have the GCE instance_id and zone as MonitoredResource
392
+ # labels; for other services we send them as entry labels.
393
+ if @resource.type == COMPUTE_CONSTANTS[:resource_type] ||
394
+ @resource.type == CONTAINER_CONSTANTS[:resource_type]
395
+ @resource.labels['instance_id'] = @vm_id
396
+ @resource.labels['zone'] = @zone
397
+ else
398
+ common_labels["#{COMPUTE_CONSTANTS[:service]}/resource_id"] = @vm_id
399
+ common_labels["#{COMPUTE_CONSTANTS[:service]}/zone"] = @zone
400
+ end
401
+ common_labels["#{COMPUTE_CONSTANTS[:service]}/resource_name"] = @vm_name
308
402
  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
403
+ @resource.type = EC2_CONSTANTS[:resource_type]
404
+ @resource.labels['instance_id'] = @vm_id
405
+ @resource.labels['region'] = @zone
406
+ # the aws_account label is populated above.
407
+ common_labels["#{EC2_CONSTANTS[:service]}/resource_name"] = @vm_name
313
408
  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
409
+ # Use GCE as the default environment.
410
+ @resource.type = COMPUTE_CONSTANTS[:resource_type]
411
+ @resource.labels['instance_id'] = @vm_id
412
+ @resource.labels['zone'] = @zone
413
+ common_labels["#{COMPUTE_CONSTANTS[:service]}/resource_name"] = @vm_name
319
414
  end
415
+ @resource.labels.merge!(
416
+ extract_resource_labels(@resource.type, common_labels))
417
+
418
+ # The resource and labels are now set up; ensure they can't be modified
419
+ # without first duping them.
420
+ @resource.freeze
421
+ @resource.labels.freeze
422
+ @common_labels.freeze
320
423
 
321
424
  # 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
425
+ @log.info 'Logs viewer address: https://console.cloud.google.com/logs/',
426
+ "viewer?project=#{@project_id}&resource=#{@resource_type}/",
427
+ "instance_id/#{@vm_id}"
325
428
  end
326
429
 
327
430
  def start
@@ -353,6 +456,112 @@ module Fluent
353
456
  tag
354
457
  end
355
458
 
459
+ # Compute the monitored resource and common labels shared by a collection of
460
+ # entries.
461
+ def compute_group_resource_and_labels(tag)
462
+ # Note that we assume that labels added to group_common_labels below are
463
+ # not 'service' labels (i.e. we do not call extract_resource_labels
464
+ # again).
465
+ group_resource = @resource.dup
466
+ group_common_labels = @common_labels.dup
467
+
468
+ if @running_cloudfunctions
469
+ # If the current group of entries is coming from a Cloud Functions
470
+ # function, the function name can be extracted from the tag.
471
+ match_data = @cloudfunctions_tag_regexp.match(tag)
472
+ if match_data
473
+ # Resource type is set to Cloud Functions only for logs actually
474
+ # coming from a function, otherwise we leave it as Container.
475
+ group_resource.type = CLOUDFUNCTIONS_CONSTANTS[:resource_type]
476
+ group_resource.labels['region'] = @gcf_region
477
+ group_resource.labels['function_name'] =
478
+ decode_cloudfunctions_function_name(
479
+ match_data['encoded_function_name'])
480
+ # Move GKE container labels from the MonitoredResource to the
481
+ # LogEntry.
482
+ instance_id = group_resource.labels.delete('instance_id')
483
+ group_common_labels["#{CONTAINER_CONSTANTS[:service]}/cluster_name"] =
484
+ group_resource.labels.delete('cluster_name')
485
+ group_common_labels["#{CONTAINER_CONSTANTS[:service]}/instance_id"] =
486
+ instance_id
487
+ group_common_labels["#{COMPUTE_CONSTANTS[:service]}/resource_id"] =
488
+ instance_id
489
+ group_common_labels["#{COMPUTE_CONSTANTS[:service]}/zone"] =
490
+ group_resource.labels.delete('zone')
491
+ end
492
+ end
493
+ if group_resource.type == CONTAINER_CONSTANTS[:resource_type] &&
494
+ @compiled_kubernetes_tag_regexp
495
+ # Container logs in Kubernetes are tagged based on where they came
496
+ # from, so we can extract useful metadata from the tag.
497
+ # Do this here to avoid having to repeat it for each record.
498
+ match_data = @compiled_kubernetes_tag_regexp.match(tag)
499
+ if match_data
500
+ group_resource.labels['container_name'] = match_data['container_name']
501
+ group_resource.labels['namespace_id'] = match_data['namespace_name']
502
+ group_resource.labels['pod_id'] = match_data['pod_name']
503
+ %w(namespace_name pod_name).each do |field|
504
+ group_common_labels["#{CONTAINER_CONSTANTS[:service]}/#{field}"] =
505
+ match_data[field]
506
+ end
507
+ end
508
+ end
509
+
510
+ # Freeze the per-request state. Any further changes must be made on a
511
+ # per-entry basis.
512
+ group_resource.freeze
513
+ group_resource.labels.freeze
514
+ group_common_labels.freeze
515
+
516
+ [group_resource, group_common_labels]
517
+ end
518
+
519
+ # Extract entry resource and common labels that should be applied to
520
+ # individual entries from the group resource.
521
+ def extract_entry_labels(group_resource, record)
522
+ resource_labels = {}
523
+ common_labels = {}
524
+
525
+ if group_resource.type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
526
+ record.key?('log')
527
+ @cloudfunctions_log_match =
528
+ @cloudfunctions_log_regexp.match(record['log'])
529
+ end
530
+
531
+ if group_resource.type == CONTAINER_CONSTANTS[:resource_type]
532
+ # Move the stdout/stderr annotation from the record into a label
533
+ common_labels.merge!(
534
+ fields_to_labels(
535
+ record, 'stream' => "#{CONTAINER_CONSTANTS[:service]}/stream"))
536
+
537
+ # If the record has been annotated by the kubernetes_metadata_filter
538
+ # plugin, then use that metadata. Otherwise, rely on commonLabels
539
+ # populated at the grouped_entries level from the group's tag.
540
+ if record.key?('kubernetes')
541
+ extracted_resource_labels, extracted_common_labels = \
542
+ extract_container_metadata(record)
543
+ resource_labels.merge!(extracted_resource_labels)
544
+ common_labels.merge!(extracted_common_labels)
545
+ end
546
+ end
547
+
548
+ # If a field is present in the label_map, send its value as a label
549
+ # (mapping the field name to label name as specified in the config)
550
+ # and do not send that field as part of the payload.
551
+ common_labels.merge!(fields_to_labels(record, @label_map))
552
+
553
+ if group_resource.type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
554
+ @cloudfunctions_log_match &&
555
+ @cloudfunctions_log_match['execution_id']
556
+ common_labels['execution_id'] =
557
+ @cloudfunctions_log_match['execution_id']
558
+ end
559
+ resource_labels.merge!(
560
+ extract_resource_labels(group_resource.type, common_labels))
561
+
562
+ [resource_labels, common_labels]
563
+ end
564
+
356
565
  def write(chunk)
357
566
  # Group the entries since we have to make one call per tag.
358
567
  grouped_entries = {}
@@ -369,74 +578,20 @@ module Fluent
369
578
 
370
579
  grouped_entries.each do |tag, arr|
371
580
  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
581
+ group_resource, group_common_labels = compute_group_resource_and_labels(
582
+ tag)
403
583
 
404
584
  arr.each do |time, record|
405
585
  next unless record.is_a?(Hash)
406
586
 
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
587
+ extracted_resource_labels, extracted_common_labels = \
588
+ extract_entry_labels(group_resource, record)
589
+ entry_resource = group_resource.dup
590
+ entry_resource.labels.merge!(extracted_resource_labels)
591
+ entry_common_labels = \
592
+ group_common_labels.merge(extracted_common_labels)
439
593
 
594
+ if entry_resource.type == CONTAINER_CONSTANTS[:resource_type]
440
595
  # Save the timestamp if available, then clear it out to allow for
441
596
  # determining whether we should parse the log or message field.
442
597
  timestamp = record.key?('time') ? record['time'] : nil
@@ -460,57 +615,47 @@ module Fluent
460
615
  end
461
616
  end
462
617
 
463
- ts_secs, ts_nanos = compute_timestamp(record, time)
618
+ ts_secs, ts_nanos = compute_timestamp(
619
+ entry_resource.type, record, time)
620
+ severity = compute_severity(
621
+ entry_resource.type, record, entry_common_labels)
622
+
464
623
  if @use_grpc
624
+ entry = Google::Logging::V2::LogEntry.new(
625
+ labels: entry_common_labels,
626
+ resource: Google::Api::MonitoredResource.new(
627
+ type: entry_resource.type,
628
+ labels: entry_resource.labels.to_h
629
+ ),
630
+ severity: grpc_severity(severity)
631
+ )
465
632
  # If "seconds" is null or not an integer, we will omit the timestamp
466
633
  # field and defer the decision on how to handle it to the downstream
467
634
  # Logging API. If "nanos" is null or not an integer, it will be set
468
635
  # to 0.
469
636
  if ts_secs.is_a?(Integer)
470
637
  ts_nanos = 0 unless ts_nanos.is_a?(Integer)
471
- entry.metadata.timestamp = Google::Protobuf::Timestamp.new(
638
+ entry.timestamp = Google::Protobuf::Timestamp.new(
472
639
  seconds: ts_secs,
473
640
  nanos: ts_nanos
474
641
  )
475
642
  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
643
  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)
644
+ set_payload_grpc(entry_resource.type, record, entry, is_json)
511
645
  else
512
- set_payload(record, entry, is_json)
513
- entry.metadata.labels = nil if entry.metadata.labels.empty?
646
+ # Remove the labels if we didn't populate them with anything.
647
+ entry_resource.labels = nil if entry_resource.labels.empty?
648
+ entry = Google::Apis::LoggingV2beta1::LogEntry.new(
649
+ labels: entry_common_labels,
650
+ resource: entry_resource,
651
+ severity: severity,
652
+ timestamp: {
653
+ seconds: ts_secs,
654
+ nanos: ts_nanos
655
+ }
656
+ )
657
+ set_http_request(record, entry)
658
+ set_payload(entry_resource.type, record, entry, is_json)
514
659
  end
515
660
 
516
661
  entries.push(entry)
@@ -518,25 +663,30 @@ module Fluent
518
663
  # Don't send an empty request if we rejected all the entries.
519
664
  next if entries.empty?
520
665
 
521
- log_name = log_name(tag, labels)
666
+ log_name = "projects/#{@project_id}/logs/#{log_name(
667
+ tag, group_resource)}"
522
668
 
669
+ # Does the actual write to the cloud logging api.
670
+ client = api_client
523
671
  if @use_grpc
524
672
  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|
673
+ labels_utf8_pairs = group_common_labels.map do |k, v|
530
674
  [k.encode('utf-8'), convert_to_utf8(v)]
531
675
  end
532
676
 
533
- write_request = Google::Logging::V1::WriteLogEntriesRequest.new(
534
- log_name: "projects/#{@project_id}/logs/#{log_name}",
535
- common_labels: Hash[labels_utf8_pairs],
677
+ write_request = Google::Logging::V2::WriteLogEntriesRequest.new(
678
+ log_name: log_name,
679
+ resource: Google::Api::MonitoredResource.new(
680
+ type: group_resource.type,
681
+ labels: group_resource.labels.to_h
682
+ ),
683
+ labels: labels_utf8_pairs.to_h,
536
684
  entries: entries
537
685
  )
538
686
 
539
687
  client.write_log_entries(write_request)
688
+ increment_successful_requests_count
689
+ increment_ingested_entries_count(entries.length)
540
690
 
541
691
  # Let the user explicitly know when the first call succeeded,
542
692
  # to aid with verification and troubleshooting.
@@ -546,10 +696,12 @@ module Fluent
546
696
  end
547
697
 
548
698
  rescue GRPC::Cancelled => error
699
+ increment_failed_requests_count(GRPC::Core::StatusCodes::CANCELLED)
549
700
  # RPC cancelled, so retry via re-raising the error.
550
701
  raise error
551
702
 
552
703
  rescue GRPC::BadStatus => error
704
+ increment_failed_requests_count(error.code)
553
705
  case error.code
554
706
  when GRPC::Core::StatusCodes::CANCELLED,
555
707
  GRPC::Core::StatusCodes::UNAVAILABLE,
@@ -564,6 +716,7 @@ module Fluent
564
716
  # Most client errors indicate a problem with the request itself
565
717
  # and should not be retried.
566
718
  dropped = entries.length
719
+ increment_dropped_entries_count(dropped)
567
720
  @log.warn "Dropping #{dropped} log message(s)",
568
721
  error: error.to_s, error_code: error.code.to_s
569
722
  when GRPC::Core::StatusCodes::UNAUTHENTICATED
@@ -571,12 +724,14 @@ module Fluent
571
724
  # These are usually solved via a `gcloud auth` call, or by
572
725
  # modifying the permissions on the Google Cloud project.
573
726
  dropped = entries.length
727
+ increment_dropped_entries_count(dropped)
574
728
  @log.warn "Dropping #{dropped} log message(s)",
575
729
  error: error.to_s, error_code: error.code.to_s
576
730
  else
577
731
  # Assume this is a problem with the request itself
578
732
  # and don't retry.
579
733
  dropped = entries.length
734
+ increment_dropped_entries_count(dropped)
580
735
  @log.error "Unknown response code #{error.code} from the "\
581
736
  "server, dropping #{dropped} log message(s)",
582
737
  error: error.to_s, error_code: error.code.to_s
@@ -584,21 +739,22 @@ module Fluent
584
739
  end
585
740
  else
586
741
  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
742
  write_request = \
596
- Google::Apis::LoggingV1beta3::WriteLogEntriesRequest.new(
597
- common_labels: labels,
743
+ Google::Apis::LoggingV2beta1::WriteLogEntriesRequest.new(
744
+ log_name: log_name,
745
+ resource: group_resource,
746
+ labels: group_common_labels,
598
747
  entries: entries)
599
748
 
600
749
  # TODO: RequestOptions
601
- client.write_log_entries(@project_id, log_name, write_request)
750
+ begin
751
+ client.write_entry_log_entries(write_request)
752
+ rescue Google::Apis::Error => error
753
+ increment_failed_requests_count(error.status_code)
754
+ raise error
755
+ end
756
+ increment_successful_requests_count
757
+ increment_ingested_entries_count(entries.length)
602
758
 
603
759
  # Let the user explicitly know when the first call succeeded,
604
760
  # to aid with verification and troubleshooting.
@@ -616,6 +772,7 @@ module Fluent
616
772
  # These are usually solved via a `gcloud auth` call, or by modifying
617
773
  # the permissions on the Google Cloud project.
618
774
  dropped = entries.length
775
+ increment_dropped_entries_count(dropped)
619
776
  @log.warn "Dropping #{dropped} log message(s)",
620
777
  error_class: error.class.to_s, error: error.to_s
621
778
 
@@ -623,6 +780,7 @@ module Fluent
623
780
  # Most ClientErrors indicate a problem with the request itself and
624
781
  # should not be retried.
625
782
  dropped = entries.length
783
+ increment_dropped_entries_count(dropped)
626
784
  @log.warn "Dropping #{dropped} log message(s)",
627
785
  error_class: error.class.to_s, error: error.to_s
628
786
  end
@@ -759,7 +917,7 @@ module Fluent
759
917
  instance_prefix
760
918
  end
761
919
 
762
- def compute_timestamp(record, time)
920
+ def compute_timestamp(resource_type, record, time)
763
921
  if record.key?('timestamp') &&
764
922
  record['timestamp'].is_a?(Hash) &&
765
923
  record['timestamp'].key?('seconds') &&
@@ -783,7 +941,7 @@ module Fluent
783
941
  @log.warn 'timeNanos is deprecated - please use ' \
784
942
  'timestampSeconds and timestampNanos instead.'
785
943
  end
786
- elsif @service_name == CLOUDFUNCTIONS_SERVICE &&
944
+ elsif resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
787
945
  @cloudfunctions_log_match
788
946
  timestamp = DateTime.parse(@cloudfunctions_log_match['timestamp'])
789
947
  ts_secs = timestamp.strftime('%s').to_i
@@ -805,8 +963,8 @@ module Fluent
805
963
  [ts_secs, ts_nanos]
806
964
  end
807
965
 
808
- def compute_severity(record, entry)
809
- if @service_name == CLOUDFUNCTIONS_SERVICE
966
+ def compute_severity(resource_type, record, entry_common_labels)
967
+ if resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type]
810
968
  if @cloudfunctions_log_match && @cloudfunctions_log_match['severity']
811
969
  return parse_severity(@cloudfunctions_log_match['severity'])
812
970
  elsif record.key?('stream') && record['stream'] == 'stdout'
@@ -820,9 +978,9 @@ module Fluent
820
978
  end
821
979
  elsif record.key?('severity')
822
980
  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"]
981
+ elsif resource_type == CONTAINER_CONSTANTS[:resource_type] &&
982
+ entry_common_labels.key?("#{CONTAINER_CONSTANTS[:service]}/stream")
983
+ stream = entry_common_labels["#{CONTAINER_CONSTANTS[:service]}/stream"]
826
984
  if stream == 'stdout'
827
985
  return 'INFO'
828
986
  elsif stream == 'stderr'
@@ -835,32 +993,19 @@ module Fluent
835
993
  end
836
994
  end
837
995
 
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
996
+ NANOS_IN_A_SECOND = 1000 * 1000 * 1000
856
997
 
857
- def set_http_request_grpc(record, entry)
998
+ def set_http_request(record, entry)
858
999
  return nil unless record['httpRequest'].is_a?(Hash)
859
1000
  input = record['httpRequest']
860
- output = Google::Logging::Type::HttpRequest.new
1001
+ if @use_grpc
1002
+ output = Google::Logging::Type::HttpRequest.new
1003
+ else
1004
+ output = Google::Apis::LoggingV2beta1::HttpRequest.new
1005
+ end
861
1006
  # 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.
1007
+ # nil. However we do not want to assign this nil value to the constructed
1008
+ # json or proto.
864
1009
  request_method = input.delete('requestMethod')
865
1010
  output.request_method = request_method unless request_method.nil?
866
1011
  request_url = input.delete('requestUrl')
@@ -884,6 +1029,33 @@ module Fluent
884
1029
  output.cache_validated_with_origin_server = \
885
1030
  cache_validated_with_origin_server \
886
1031
  unless cache_validated_with_origin_server.nil?
1032
+
1033
+ latency = input.delete('latency')
1034
+ unless latency.nil?
1035
+ # Parse latency. If no valid format is detected, skip setting latency.
1036
+ # Format: whitespace (optional) + integer + point & decimal (optional)
1037
+ # + whitespace (optional) + "s" + whitespace (optional)
1038
+ # e.g.: "1.42 s"
1039
+ match = @http_latency_regexp.match(latency)
1040
+ if match
1041
+ # Split the integer and decimal parts in order to calculate seconds
1042
+ # and nanos.
1043
+ latency_seconds = match['seconds'].to_i
1044
+ latency_nanos = (match['decimal'].to_f * NANOS_IN_A_SECOND).round
1045
+ if @use_grpc
1046
+ output.latency = Google::Protobuf::Duration.new(
1047
+ seconds: latency_seconds,
1048
+ nanos: latency_nanos
1049
+ )
1050
+ else
1051
+ output.latency = {
1052
+ seconds: latency_seconds,
1053
+ nanos: latency_nanos
1054
+ }.delete_if { |_, v| v == 0 }
1055
+ end
1056
+ end
1057
+ end
1058
+
887
1059
  record.delete('httpRequest') if input.empty?
888
1060
  entry.http_request = output
889
1061
  end
@@ -989,16 +1161,23 @@ module Fluent
989
1161
  end
990
1162
 
991
1163
  # 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}")
1164
+ def extract_container_metadata(record)
1165
+ resource_labels = {}
1166
+ common_labels = {}
1167
+ %w(namespace_id pod_id container_name).each do |field|
1168
+ resource_labels.merge!(
1169
+ fields_to_labels(record['kubernetes'], field => field))
1170
+ end
1171
+ %w(namespace_name pod_name).each do |field|
1172
+ common_labels.merge!(
1173
+ fields_to_labels(
1174
+ record['kubernetes'],
1175
+ field => "#{CONTAINER_CONSTANTS[:service]}/#{field}"))
997
1176
  end
998
1177
  # Prepend label/ to all user-defined labels' keys.
999
1178
  if record['kubernetes'].key?('labels')
1000
1179
  record['kubernetes']['labels'].each do |key, value|
1001
- entry.metadata.labels["label/#{key}"] = value
1180
+ common_labels["label/#{key}"] = value
1002
1181
  end
1003
1182
  end
1004
1183
  # We've explicitly consumed all the fields we care about -- don't litter
@@ -1006,33 +1185,43 @@ module Fluent
1006
1185
  # filter plugin includes (or an empty 'kubernetes' field).
1007
1186
  record.delete('kubernetes')
1008
1187
  record.delete('docker')
1188
+ [resource_labels, common_labels]
1009
1189
  end
1010
1190
 
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)
1191
+ # For every original_label => new_label pair in the label_map, delete the
1192
+ # original_label from the record if it exists, and extract the value to form
1193
+ # a map with the new_label as the key.
1194
+ def fields_to_labels(record, label_map)
1195
+ return {} if label_map.nil? || !label_map.is_a?(Hash)
1196
+ label_map.each_with_object({}) \
1197
+ do |(original_label, new_label), extracted_labels|
1198
+ extracted_labels[new_label] = convert_to_utf8(
1199
+ record.delete(original_label).to_s) if record.key?(original_label)
1200
+ end
1015
1201
  end
1016
1202
 
1017
- def set_payload(record, entry, is_json)
1203
+ def set_payload(resource_type, record, entry, is_json)
1018
1204
  # If this is a Cloud Functions log that matched the expected regexp,
1019
1205
  # use text payload. Otherwise, use JSON if we found valid JSON, or text
1020
1206
  # payload in the following cases:
1021
1207
  # 1. This is a Cloud Functions log and the 'log' key is available
1022
1208
  # 2. This is an unstructured Container log and the 'log' key is available
1023
1209
  # 3. The only remaining key is 'message'
1024
- if @service_name == CLOUDFUNCTIONS_SERVICE && @cloudfunctions_log_match
1210
+ if resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
1211
+ @cloudfunctions_log_match
1025
1212
  entry.text_payload = @cloudfunctions_log_match['text']
1026
- elsif @service_name == CLOUDFUNCTIONS_SERVICE && record.key?('log')
1213
+ elsif resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
1214
+ record.key?('log')
1027
1215
  entry.text_payload = record['log']
1028
1216
  elsif is_json
1029
- entry.struct_payload = record
1030
- elsif @service_name == CONTAINER_SERVICE && record.key?('log')
1217
+ entry.json_payload = record
1218
+ elsif resource_type == CONTAINER_CONSTANTS[:resource_type] &&
1219
+ record.key?('log')
1031
1220
  entry.text_payload = record['log']
1032
1221
  elsif record.size == 1 && record.key?('message')
1033
1222
  entry.text_payload = record['message']
1034
1223
  else
1035
- entry.struct_payload = record
1224
+ entry.json_payload = record
1036
1225
  end
1037
1226
  end
1038
1227
 
@@ -1080,56 +1269,82 @@ module Fluent
1080
1269
  ret
1081
1270
  end
1082
1271
 
1083
- def set_payload_grpc(record, entry, is_json)
1272
+ def set_payload_grpc(resource_type, record, entry, is_json)
1084
1273
  # If this is a Cloud Functions log that matched the expected regexp,
1085
1274
  # use text payload. Otherwise, use JSON if we found valid JSON, or text
1086
1275
  # payload in the following cases:
1087
1276
  # 1. This is a Cloud Functions log and the 'log' key is available
1088
1277
  # 2. This is an unstructured Container log and the 'log' key is available
1089
1278
  # 3. The only remaining key is 'message'
1090
- if @service_name == CLOUDFUNCTIONS_SERVICE && @cloudfunctions_log_match
1279
+ if resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
1280
+ @cloudfunctions_log_match
1091
1281
  entry.text_payload = convert_to_utf8(
1092
1282
  @cloudfunctions_log_match['text'])
1093
- elsif @service_name == CLOUDFUNCTIONS_SERVICE && record.key?('log')
1283
+ elsif resource_type == CLOUDFUNCTIONS_CONSTANTS[:resource_type] &&
1284
+ record.key?('log')
1094
1285
  entry.text_payload = convert_to_utf8(record['log'])
1095
1286
  elsif is_json
1096
- entry.struct_payload = struct_from_ruby(record)
1097
- elsif @service_name == CONTAINER_SERVICE && record.key?('log')
1287
+ entry.json_payload = struct_from_ruby(record)
1288
+ elsif resource_type == CONTAINER_CONSTANTS[:resource_type] &&
1289
+ record.key?('log')
1098
1290
  entry.text_payload = convert_to_utf8(record['log'])
1099
1291
  elsif record.size == 1 && record.key?('message')
1100
1292
  entry.text_payload = convert_to_utf8(record['message'])
1101
1293
  else
1102
- entry.struct_payload = struct_from_ruby(record)
1294
+ entry.json_payload = struct_from_ruby(record)
1103
1295
  end
1104
1296
  end
1105
1297
 
1106
- def log_name(tag, common_labels)
1107
- if @service_name == CLOUDFUNCTIONS_SERVICE
1298
+ def log_name(tag, resource)
1299
+ if resource.type == CLOUDFUNCTIONS_CONSTANTS[:resource_type]
1108
1300
  tag = 'cloud-functions'
1109
1301
  elsif @running_on_managed_vm
1110
1302
  # Add a prefix to Managed VM logs to prevent namespace collisions.
1111
- tag = "#{APPENGINE_SERVICE}/#{tag}"
1112
- elsif @service_name == CONTAINER_SERVICE
1303
+ tag = "#{APPENGINE_CONSTANTS[:service]}/#{tag}"
1304
+ elsif resource.type == CONTAINER_CONSTANTS[:resource_type]
1113
1305
  # For Kubernetes logs, use just the container name as the log name
1114
1306
  # 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?
1307
+ if resource.labels && resource.labels.key?('container_name')
1308
+ sanitized_tag = sanitize_tag(resource.labels['container_name'])
1309
+ tag = sanitized_tag unless sanitized_tag.nil?
1119
1310
  end
1120
1311
  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
1312
+ tag = ERB::Util.url_encode(tag)
1124
1313
  tag
1125
1314
  end
1126
1315
 
1316
+ # Some services set labels (via configuring 'labels' or 'label_map') which
1317
+ # are now MonitoredResource labels in v2.
1318
+ # For these services, remove resource labels from 'labels' and return a
1319
+ # Hash of labels to be merged into the MonitoredResource labels.
1320
+ # Otherwise, return an empty hash and leave 'labels' unmodified.
1321
+ def extract_resource_labels(resource_type, labels)
1322
+ extracted_labels = {}
1323
+ return extracted_labels if labels.nil? || !labels.is_a?(Hash)
1324
+
1325
+ if resource_type == DATAFLOW_CONSTANTS[:resource_type]
1326
+ label_prefix = DATAFLOW_CONSTANTS[:service]
1327
+ labels_to_extract = %w(region job_name job_id step_id)
1328
+ elsif resource_type == ML_CONSTANTS[:resource_type]
1329
+ label_prefix = ML_CONSTANTS[:service]
1330
+ labels_to_extract = %w(job_id task_name)
1331
+ else
1332
+ return extracted_labels
1333
+ end
1334
+
1335
+ labels_to_extract.each do |label|
1336
+ extracted_labels[label] = labels.delete("#{label_prefix}/#{label}") if
1337
+ labels.key?("#{label_prefix}/#{label}")
1338
+ end
1339
+ extracted_labels
1340
+ end
1341
+
1127
1342
  def init_api_client
1128
1343
  return if @use_grpc
1129
1344
  # TODO: Use a non-default ClientOptions object.
1130
1345
  Google::Apis::ClientOptions.default.application_name = PLUGIN_NAME
1131
1346
  Google::Apis::ClientOptions.default.application_version = PLUGIN_VERSION
1132
- @client = Google::Apis::LoggingV1beta3::LoggingService.new
1347
+ @client = Google::Apis::LoggingV2beta1::LoggingService.new
1133
1348
  @client.authorization = Google::Auth.get_application_default(
1134
1349
  LOGGING_SCOPE)
1135
1350
  end
@@ -1140,7 +1355,7 @@ module Fluent
1140
1355
  authentication = Google::Auth.get_application_default
1141
1356
  creds = GRPC::Core::CallCredentials.new(authentication.updater_proc)
1142
1357
  creds = ssl_creds.compose(creds)
1143
- @client = Google::Logging::V1::LoggingService::Stub.new(
1358
+ @client = Google::Logging::V2::LoggingServiceV2::Stub.new(
1144
1359
  'logging.googleapis.com', creds)
1145
1360
  else
1146
1361
  unless @client.authorization.expired?
@@ -1180,5 +1395,47 @@ module Fluent
1180
1395
  end
1181
1396
  end
1182
1397
  end
1398
+
1399
+ # Increment the metric for the number of successful requests.
1400
+ def increment_successful_requests_count
1401
+ return unless @successful_requests_count
1402
+ @successful_requests_count.increment(grpc: @use_grpc)
1403
+ end
1404
+
1405
+ # Increment the metric for the number of failed requests, labeled by
1406
+ # the provided status code.
1407
+ def increment_failed_requests_count(code)
1408
+ return unless @failed_requests_count
1409
+ @failed_requests_count.increment(grpc: @use_grpc, code: code)
1410
+ end
1411
+
1412
+ # Increment the metric for the number of log entries, successfully
1413
+ # ingested by the Stackdriver Logging API.
1414
+ def increment_ingested_entries_count(count)
1415
+ return unless @ingested_entries_count
1416
+ @ingested_entries_count.increment({}, count)
1417
+ end
1418
+
1419
+ # Increment the metric for the number of log entries that were dropped
1420
+ # and not ingested by the Stackdriver Logging API.
1421
+ def increment_dropped_entries_count(count)
1422
+ return unless @dropped_entries_count
1423
+ @dropped_entries_count.increment({}, count)
1424
+ end
1425
+ end
1426
+ end
1427
+
1428
+ module Google
1429
+ module Apis
1430
+ module LoggingV2beta1
1431
+ # Override MonitoredResource::dup to make a deep copy.
1432
+ class MonitoredResource
1433
+ def dup
1434
+ ret = super
1435
+ ret.labels = labels.dup
1436
+ ret
1437
+ end
1438
+ end
1439
+ end
1183
1440
  end
1184
1441
  end