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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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