pug-sh 0.0.1

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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +247 -0
  4. data/lib/pug/batcher.rb +384 -0
  5. data/lib/pug/client.rb +248 -0
  6. data/lib/pug/connect.rb +291 -0
  7. data/lib/pug/countries.rb +19 -0
  8. data/lib/pug/errors.rb +16 -0
  9. data/lib/pug/event_builder.rb +522 -0
  10. data/lib/pug/gen/buf/validate/validate_pb.rb +59 -0
  11. data/lib/pug/gen/common/events/v1/api_events_pb.rb +26 -0
  12. data/lib/pug/gen/common/events/v1/app_events_pb.rb +33 -0
  13. data/lib/pug/gen/common/events/v1/auth_events_pb.rb +32 -0
  14. data/lib/pug/gen/common/events/v1/billing_events_pb.rb +41 -0
  15. data/lib/pug/gen/common/events/v1/chat_events_pb.rb +56 -0
  16. data/lib/pug/gen/common/events/v1/commerce_events_pb.rb +38 -0
  17. data/lib/pug/gen/common/events/v1/discovery_events_pb.rb +31 -0
  18. data/lib/pug/gen/common/events/v1/error_events_pb.rb +26 -0
  19. data/lib/pug/gen/common/events/v1/file_events_pb.rb +29 -0
  20. data/lib/pug/gen/common/events/v1/form_events_pb.rb +27 -0
  21. data/lib/pug/gen/common/events/v1/integration_events_pb.rb +27 -0
  22. data/lib/pug/gen/common/events/v1/invitation_events_pb.rb +26 -0
  23. data/lib/pug/gen/common/events/v1/media_events_pb.rb +36 -0
  24. data/lib/pug/gen/common/events/v1/navigation_events_pb.rb +31 -0
  25. data/lib/pug/gen/common/events/v1/notification_events_pb.rb +28 -0
  26. data/lib/pug/gen/common/events/v1/options_pb.rb +25 -0
  27. data/lib/pug/gen/common/events/v1/social_events_pb.rb +25 -0
  28. data/lib/pug/gen/common/events/v1/support_events_pb.rb +33 -0
  29. data/lib/pug/gen/common/events/v1/workspace_events_pb.rb +30 -0
  30. data/lib/pug/gen/common/v1/filter_schema_pb.rb +29 -0
  31. data/lib/pug/gen/common/v1/filters_pb.rb +27 -0
  32. data/lib/pug/gen/common/v1/property_value_pb.rb +24 -0
  33. data/lib/pug/gen/common/v1/time_pb.rb +25 -0
  34. data/lib/pug/gen/sdk/devices/v1/devices_pb.rb +35 -0
  35. data/lib/pug/gen/sdk/events/v1/events_pb.rb +30 -0
  36. data/lib/pug/gen/sdk/profiles/v1/profiles_pb.rb +28 -0
  37. data/lib/pug/gen/shared/activity/v1/activity_pb.rb +46 -0
  38. data/lib/pug/gen/shared/insights/v1/insights_pb.rb +69 -0
  39. data/lib/pug/gen/shared/profiles/v1/profiles_pb.rb +43 -0
  40. data/lib/pug/log.rb +35 -0
  41. data/lib/pug/proto.rb +22 -0
  42. data/lib/pug/reads.rb +118 -0
  43. data/lib/pug/rpc.rb +73 -0
  44. data/lib/pug/validator.rb +475 -0
  45. data/lib/pug/version.rb +5 -0
  46. data/lib/pug/well_known.rb +33 -0
  47. data/lib/pug-sh.rb +3 -0
  48. data/lib/pug.rb +5 -0
  49. metadata +122 -0
@@ -0,0 +1,522 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+ require_relative "batcher"
8
+ require_relative "countries"
9
+ require_relative "log"
10
+ require_relative "proto"
11
+ require_relative "validator"
12
+ require_relative "version"
13
+ require_relative "well_known"
14
+
15
+ module Pug
16
+ # Builds and validates the Event for a track call. build returns [event, encoded bytesize],
17
+ # or nil, having logged why, when the event must be dropped.
18
+ class EventBuilder
19
+ LIB = "pug-ruby"
20
+ PLATFORM = "server"
21
+
22
+ # PropertyValue.string_value is capped at 1024 code points. A UTF-8 byte count is always at
23
+ # least the code-point count, so a 1024-byte cap is stricter, never looser.
24
+ MAX_STRING_BYTES = 1024
25
+
26
+ # An event too big for a batch of its own would be retried forever.
27
+ MAX_EVENT_BYTES = Batcher::MAX_BATCH_BYTES
28
+
29
+ NANOS = 1_000_000_000
30
+ # Past 2^53 a Float may already have been rounded, so it can't stand for an exact integer.
31
+ MAX_SAFE_FLOAT = 2**53
32
+
33
+ INTEGER_RANGES = {
34
+ int32: (-(2**31))..((2**31) - 1),
35
+ sint32: (-(2**31))..((2**31) - 1),
36
+ sfixed32: (-(2**31))..((2**31) - 1),
37
+ int64: (-(2**63))..((2**63) - 1),
38
+ sint64: (-(2**63))..((2**63) - 1),
39
+ sfixed64: (-(2**63))..((2**63) - 1),
40
+ uint32: 0..((2**32) - 1),
41
+ fixed32: 0..((2**32) - 1),
42
+ uint64: 0..((2**64) - 1),
43
+ fixed64: 0..((2**64) - 1)
44
+ }.freeze
45
+
46
+ # Proto Timestamp tops out at year 9999. A time past it, or before the epoch, is almost
47
+ # certainly a unit mistake.
48
+ MIN_OCCUR_TIME = Time.at(0).utc
49
+ MAX_OCCUR_TIME = Time.utc(9999, 12, 31, 23, 59, 59)
50
+
51
+ LOCATION_STRINGS = {
52
+ "continent" => "$continent",
53
+ "country" => "$country",
54
+ "region" => "$region",
55
+ "city" => "$city",
56
+ "postal_code" => "$postalCode",
57
+ "metro_code" => "$metroCode",
58
+ "timezone" => "$timezone"
59
+ }.freeze
60
+ LOCATION_COORDINATES = { "latitude" => 90, "longitude" => 180 }.freeze
61
+ LOCATION_ALIASES = { "postalCode" => "postal_code", "metroCode" => "metro_code" }.freeze
62
+ LOCATION_FIELD_NAMES = (LOCATION_STRINGS.keys + LOCATION_COORDINATES.keys).join(", ")
63
+
64
+ Event = Proto::Sdk::Events::V1::Event
65
+ Duration = ::Google::Protobuf::Duration
66
+ Timestamp = ::Google::Protobuf::Timestamp
67
+
68
+ SchemaField = Struct.new(:descriptor, :name, :json_name, :type)
69
+ Schema = Struct.new(:klass, :name, :by_key, :fields)
70
+
71
+ # Field lookups for every well-known schema, by proto name and JSON name, worked out once.
72
+ SCHEMAS = WellKnown::CATALOG.transform_values do |klass|
73
+ fields = klass.descriptor.map do |field|
74
+ SchemaField.new(field, field.name, field.json_name, field.type).freeze
75
+ end
76
+ by_key = fields.flat_map { |field| [[field.name, field], [field.json_name, field]] }.to_h
77
+ Schema.new(klass, klass.descriptor.name, by_key.freeze, fields.freeze).freeze
78
+ end.freeze
79
+
80
+ class FieldError < StandardError
81
+ attr_reader :field
82
+
83
+ def initialize(field, message)
84
+ @field = field
85
+ super(message)
86
+ end
87
+
88
+ def to_s
89
+ "#{field}: #{super}"
90
+ end
91
+ end
92
+
93
+ class NotRepresentable < StandardError; end
94
+
95
+ Unsupported = Object.new.freeze
96
+
97
+ # options.proto's wire form for a Duration: "1.5s", never "1.500s". Summing first fixes nanos
98
+ # that are out of range or signed against the seconds.
99
+ def self.format_duration(seconds, nanos)
100
+ total = (seconds * NANOS) + nanos
101
+ whole, fraction = total.abs.divmod(NANOS)
102
+ fraction = format("%09d", fraction).sub(/0+\z/, "")
103
+ "#{"-" if total.negative?}#{whole}#{".#{fraction}" unless fraction.empty?}s"
104
+ end
105
+
106
+ # JSON.generate writes an object it doesn't know, such as a Set, as its to_s. Here times become
107
+ # ISO 8601 and other real numbers floats, as they would on their own, and anything else is
108
+ # refused, as is an integer past max_integer. Like JSON, it stops at 100 levels, for cycles.
109
+ def self.json_ready(value, max_integer: nil, depth: 0)
110
+ raise NotRepresentable, "nested more than 100 levels deep" if depth > 100
111
+
112
+ case value
113
+ when Hash then value.to_h { |key, item| [key.to_s, json_ready(item, max_integer:, depth: depth + 1)] }
114
+ when Array then value.map { |item| json_ready(item, max_integer:, depth: depth + 1) }
115
+ when String, Float, true, false, nil then value
116
+ when Integer
117
+ raise NotRepresentable, "#{value} is out of range (±#{max_integer})" if max_integer && value.abs > max_integer
118
+
119
+ value
120
+ when Symbol then value.to_s
121
+ when Time, DateTime then value.iso8601(3)
122
+ when Date then value.iso8601
123
+ else
124
+ raise NotRepresentable, "#{value.class} has no JSON form" unless value.is_a?(Numeric) && value.real?
125
+
126
+ Float(value)
127
+ end
128
+ end
129
+
130
+ def initialize(log)
131
+ @log = log
132
+ end
133
+
134
+ def build(kind, session_id, distinct_id, properties = nil, timestamp: nil, location: nil)
135
+ kind = utf8(kind.to_s)
136
+ schema = SCHEMAS[kind]
137
+ custom = if schema
138
+ well_known_properties(schema, kind, properties || {})
139
+ else
140
+ custom_properties(properties || {}, kind)
141
+ end
142
+ return if custom.nil?
143
+
144
+ auto = {
145
+ "$lib" => string_value(LIB),
146
+ "$sdkVersion" => string_value(VERSION),
147
+ "$platform" => string_value(PLATFORM),
148
+ **location_properties(location, kind)
149
+ }
150
+ event = new_event(kind, session_id, distinct_id, auto, custom, timestamp)
151
+ return if event.nil?
152
+
153
+ violations = Validator.validate(event)
154
+ unless violations.empty?
155
+ @log.error("Event \"#{kind}\" (#{schema ? "well-known" : "custom"}) failed Event-level validation: " \
156
+ "#{Validator.format(violations)}")
157
+ return
158
+ end
159
+ bytesize = Event.encode(event).bytesize
160
+ if bytesize > MAX_EVENT_BYTES
161
+ @log.error("Event \"#{kind}\" dropped: #{bytesize} bytes is too large to send (limit #{MAX_EVENT_BYTES})")
162
+ return
163
+ end
164
+ [event, bytesize]
165
+ end
166
+
167
+ private
168
+
169
+ def utf8(string)
170
+ unless [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(string.encoding)
171
+ string = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
172
+ end
173
+ string = string.dup.force_encoding(Encoding::UTF_8) unless string.encoding == Encoding::UTF_8
174
+ string.valid_encoding? ? string : string.scrub("\uFFFD")
175
+ end
176
+
177
+ def time?(value)
178
+ value.is_a?(Time) || value.is_a?(DateTime)
179
+ end
180
+
181
+ def real_number?(value)
182
+ value.is_a?(Numeric) && value.real?
183
+ end
184
+
185
+ def type_name(value)
186
+ value.nil? ? "nil" : value.class.name
187
+ end
188
+
189
+ def new_event(kind, session_id, distinct_id, auto, custom, timestamp)
190
+ Event.new(
191
+ event_id: SecureRandom.uuid_v7,
192
+ auto_properties: auto,
193
+ custom_properties: custom,
194
+ kind: kind,
195
+ session_id: session_id,
196
+ distinct_id: distinct_id,
197
+ occur_time: occur_time(timestamp)
198
+ )
199
+ rescue StandardError => e
200
+ @log.error("Event \"#{kind}\" dropped: failed to create Event proto: #{Log.describe(e)}")
201
+ nil
202
+ end
203
+
204
+ def occur_time(timestamp)
205
+ return Timestamp.from_time(Time.now) if timestamp.nil?
206
+
207
+ time = timestamp.to_time if time?(timestamp)
208
+ return Timestamp.from_time(time) if time&.between?(MIN_OCCUR_TIME, MAX_OCCUR_TIME)
209
+
210
+ @log.warn("Ignoring invalid track timestamp #{timestamp.inspect}; expected a Time between 1970 and 9999. " \
211
+ "Using current time.")
212
+ Timestamp.from_time(Time.now)
213
+ end
214
+
215
+ # Typed fields render by their proto type rather than their Ruby value, so a whole-number
216
+ # double stays a double. Keys may be the field's proto name or its JSON name; unknown keys
217
+ # ride along as custom properties.
218
+ def well_known_properties(schema, kind, properties)
219
+ message, extras = typed_message(schema, properties)
220
+ violations = Validator.validate(message)
221
+ unless violations.empty?
222
+ @log.error("Event \"#{kind}\" dropped: properties validation failed for \"#{schema.name}\": " \
223
+ "#{Validator.format(violations)}")
224
+ return
225
+ end
226
+ known_properties(schema, message).merge(custom_properties(extras, kind))
227
+ rescue FieldError => e
228
+ @log.error("Event \"#{kind}\" dropped: invalid properties for \"#{schema.name}\": #{e}")
229
+ nil
230
+ end
231
+
232
+ def typed_message(schema, properties)
233
+ given = {}
234
+ extras = {}
235
+ properties.each do |key, value|
236
+ name = utf8(key.to_s)
237
+ field = schema.by_key[name]
238
+ if field.nil?
239
+ extras[name] = value
240
+ elsif given.key?(field)
241
+ raise FieldError.new(field.name, "given twice, as #{given[field].first} and #{name}")
242
+ else
243
+ given[field] = [name, value]
244
+ end
245
+ end
246
+ init = given.each_with_object({}) do |(field, (_, value)), out|
247
+ next if value.nil?
248
+
249
+ coerced = coerce(field.descriptor, value)
250
+ out[field.name] = coerced unless coerced.equal?(Unsupported)
251
+ end
252
+ [build_message(schema.klass, init), extras]
253
+ end
254
+
255
+ def build_message(klass, init)
256
+ klass.new(init)
257
+ rescue ArgumentError, TypeError, RangeError, ::Google::Protobuf::TypeError => e
258
+ raise FieldError.new(init.keys.join(", "), e.message)
259
+ end
260
+
261
+ # Ruby's protobuf type-checks assignments too, but coercing first keeps the messages uniform
262
+ # and decides the Ruby-specific cases: a Symbol is a string, an integral Float is an integer.
263
+ def coerce(field, value)
264
+ return unsupported(field) if field.label == :repeated
265
+
266
+ case field.type
267
+ when :string
268
+ return utf8(value.to_s) if value.is_a?(String) || value.is_a?(Symbol)
269
+
270
+ raise FieldError.new(field.name, "expected string, got #{type_name(value)}")
271
+ when :bool
272
+ return value if [true, false].include?(value)
273
+
274
+ raise FieldError.new(field.name, "expected bool, got #{type_name(value)}")
275
+ when :double, :float
276
+ return Float(value) if real_number?(value)
277
+
278
+ raise FieldError.new(field.name, "expected #{field.type}, got #{type_name(value)}")
279
+ when *INTEGER_RANGES.keys
280
+ integer(field.name, field.type, value)
281
+ when :message
282
+ return duration(field.name, value) if field.subtype.name == "google.protobuf.Duration"
283
+
284
+ unsupported(field)
285
+ else
286
+ unsupported(field)
287
+ end
288
+ end
289
+
290
+ def unsupported(field)
291
+ @log.warn("Field \"#{field.name}\" has unsupported type #{field.subtype&.name || field.type}, skipping")
292
+ Unsupported
293
+ end
294
+
295
+ def integer(name, type, value)
296
+ number = case value
297
+ when Integer then value
298
+ when Float then integral_float(name, type, value)
299
+ else
300
+ unless real_number?(value) && value.finite? && value == value.truncate
301
+ raise FieldError.new(name, "expected #{type}, got #{type_name(value)}")
302
+ end
303
+
304
+ value.to_i
305
+ end
306
+ return number if INTEGER_RANGES.fetch(type).cover?(number)
307
+
308
+ raise FieldError.new(name, "expected #{type}, got #{number}")
309
+ end
310
+
311
+ def integral_float(name, type, value)
312
+ raise FieldError.new(name, "expected #{type}, got #{value}") unless value.finite? && value == value.truncate
313
+ raise FieldError.new(name, "expected a safe integer, got #{value}") if value.abs >= MAX_SAFE_FLOAT
314
+
315
+ value.to_i
316
+ end
317
+
318
+ # A Numeric is seconds, Ruby's unit for time spans (and what google-protobuf itself accepts
319
+ # for a Duration field).
320
+ def duration(name, value)
321
+ case value
322
+ when Duration then value
323
+ when Hash then duration_from_hash(name, value)
324
+ else
325
+ unless real_number?(value)
326
+ raise FieldError.new(name, "expected a Duration (seconds, { seconds:, nanos: } or " \
327
+ "Google::Protobuf::Duration), got #{type_name(value)}")
328
+ end
329
+ raise FieldError.new(name, "expected a finite number of seconds, got #{value}") unless value.finite?
330
+
331
+ total = (Rational(value) * NANOS).round
332
+ seconds, nanos = total.abs.divmod(NANOS)
333
+ sign = total.negative? ? -1 : 1
334
+ Duration.new(seconds: sign * seconds, nanos: sign * nanos)
335
+ end
336
+ end
337
+
338
+ def duration_from_hash(name, value)
339
+ parts = value.transform_keys(&:to_s)
340
+ unless (parts.keys - %w[seconds nanos]).empty?
341
+ raise FieldError.new(name, "expected a Duration ({ seconds:, nanos: }), got keys #{parts.keys.join(", ")}")
342
+ end
343
+
344
+ Duration.new(
345
+ seconds: parts["seconds"].nil? ? 0 : integer("#{name}: seconds", :int64, parts["seconds"]),
346
+ nanos: parts["nanos"].nil? ? 0 : integer("#{name}: nanos", :int32, parts["nanos"])
347
+ )
348
+ end
349
+
350
+ def known_properties(schema, message)
351
+ schema.fields.each_with_object({}) do |field, out|
352
+ next if field.descriptor.label == :repeated || !Validator.set?(message, field.descriptor)
353
+
354
+ property = known_value(field, field.descriptor.get(message))
355
+ if property
356
+ out[field.json_name] = property
357
+ else
358
+ @log.warn("Field \"#{schema.name}.#{field.name}\" has unsupported type #{field.type}, skipping")
359
+ end
360
+ end
361
+ end
362
+
363
+ def known_value(field, value)
364
+ case field.type
365
+ when :string then string_value(value)
366
+ when :bool then { bool_value: value }
367
+ when :double, :float then { double_value: value }
368
+ when *INTEGER_RANGES.keys
369
+ { int_value: value } if INTEGER_RANGES[:int64].cover?(value)
370
+ when :message
371
+ string_value(self.class.format_duration(value.seconds, value.nanos)) if value.is_a?(Duration)
372
+ end
373
+ end
374
+
375
+ def custom_properties(properties, kind)
376
+ properties.each_with_object({}) do |(key, value), out|
377
+ mapped = custom_value(value)
378
+ out[utf8(key.to_s)] = mapped if mapped
379
+ rescue NotRepresentable => e
380
+ @log.warn("Property \"#{key}\" on event \"#{kind}\" not representable (#{e.message}), skipping")
381
+ end
382
+ end
383
+
384
+ # A nil result means the value is absent and the property is omitted.
385
+ def custom_value(value)
386
+ case value
387
+ when nil then nil
388
+ when String, Symbol then string_value(value.to_s)
389
+ when true, false then { bool_value: value }
390
+ when Integer
391
+ raise NotRepresentable, "#{value} overflows int64" unless INTEGER_RANGES[:int64].cover?(value)
392
+
393
+ { int_value: value }
394
+ when Float then double_value(value)
395
+ when Timestamp then { timestamp_value: value }
396
+ when Duration then string_value(self.class.format_duration(value.seconds, value.nanos))
397
+ when Hash, Array then json_value(value)
398
+ else other_value(value)
399
+ end
400
+ end
401
+
402
+ def other_value(value)
403
+ if time?(value)
404
+ time = value.to_time
405
+ raise NotRepresentable, "#{value} is out of the timestamp range" unless time.getutc.year.between?(1, 9999)
406
+
407
+ { timestamp_value: Timestamp.from_time(time) }
408
+ elsif value.is_a?(Date)
409
+ string_value(value.iso8601)
410
+ elsif real_number?(value)
411
+ double_value(Float(value))
412
+ else
413
+ raise NotRepresentable, "unsupported type #{type_name(value)}"
414
+ end
415
+ end
416
+
417
+ def double_value(value)
418
+ raise NotRepresentable, "non-finite number #{value}" unless value.finite?
419
+
420
+ { double_value: value }
421
+ end
422
+
423
+ def json_value(value)
424
+ string_value(JSON.generate(self.class.json_ready(value)))
425
+ rescue StandardError => e
426
+ raise NotRepresentable, "not JSON-serializable: #{e.message}"
427
+ end
428
+
429
+ def string_value(raw)
430
+ value = utf8(raw)
431
+ if value.bytesize > MAX_STRING_BYTES
432
+ @log.warn("Property string exceeds #{MAX_STRING_BYTES} bytes, truncating")
433
+ value = value.byteslice(0, MAX_STRING_BYTES).scrub("")
434
+ end
435
+ { string_value: value }
436
+ end
437
+
438
+ # The server skips geo enrichment on private-key requests, which is all this SDK makes, so
439
+ # these keys are the only geo the event will ever carry: a dropped field is a permanent hole,
440
+ # not one filled in later.
441
+ def location_properties(location, kind)
442
+ return {} if location.nil?
443
+
444
+ unless location.is_a?(Hash)
445
+ @log.warn("Ignoring location on event \"#{kind}\": expected a Hash, got #{type_name(location)}")
446
+ return {}
447
+ end
448
+ fields = location_fields(location, kind)
449
+ props = location_strings(fields, kind).merge(location_coordinates(fields, kind))
450
+ @log.warn("Ignoring location on event \"#{kind}\": no usable fields; the event will carry no geo") if props.empty?
451
+ props
452
+ end
453
+
454
+ def location_fields(location, kind)
455
+ location.each_with_object({}) do |(key, value), fields|
456
+ name = LOCATION_ALIASES.fetch(key.to_s, key.to_s)
457
+ if !LOCATION_STRINGS.key?(name) && !LOCATION_COORDINATES.key?(name)
458
+ @log.warn("Ignoring unknown location field \"#{key}\" on event \"#{kind}\"; expected one of " \
459
+ "#{LOCATION_FIELD_NAMES}")
460
+ elsif fields.key?(name)
461
+ @log.warn("Ignoring location field \"#{key}\" on event \"#{kind}\": #{name} is already set")
462
+ else
463
+ fields[name] = value
464
+ end
465
+ end
466
+ end
467
+
468
+ def location_strings(fields, kind)
469
+ LOCATION_STRINGS.each_with_object({}) do |(name, property), props|
470
+ value = fields[name]
471
+ next if value.nil?
472
+
473
+ unless value.is_a?(String) || value.is_a?(Symbol)
474
+ @log.warn("Ignoring location.#{name} on event \"#{kind}\": expected a String, got " \
475
+ "#{type_name(value)}")
476
+ next
477
+ end
478
+ trimmed = utf8(value.to_s).gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
479
+ next if trimmed.empty?
480
+
481
+ if name == "country"
482
+ trimmed = trimmed.upcase
483
+ unless COUNTRY_CODES.include?(trimmed)
484
+ @log.warn("Ignoring location.country #{value.to_s.inspect} on event \"#{kind}\"; expected an " \
485
+ "ISO 3166-1 alpha-2 code like \"DE\"")
486
+ next
487
+ end
488
+ end
489
+ props[property] = string_value(trimmed)
490
+ end
491
+ end
492
+
493
+ # Always doubles: a whole-number coordinate in the int slot would split the column every
494
+ # other writer of $latitude/$longitude files as Float64. A lone coordinate reads as a real
495
+ # position with the other axis at 0, so they are sent together or not at all.
496
+ def location_coordinates(fields, kind)
497
+ coordinates = LOCATION_COORDINATES.each_with_object({}) do |(name, limit), found|
498
+ value = fields[name]
499
+ next if value.nil?
500
+
501
+ unless real_number?(value)
502
+ @log.warn("Ignoring location.#{name} on event \"#{kind}\": expected a number, got " \
503
+ "#{type_name(value)}")
504
+ next
505
+ end
506
+ number = Float(value)
507
+ unless number.finite? && number.abs <= limit
508
+ @log.warn("Ignoring location.#{name} #{value} on event \"#{kind}\"; expected a number within ±#{limit}")
509
+ next
510
+ end
511
+ found[name] = number
512
+ end
513
+ if coordinates.size == 1
514
+ @log.warn("Ignoring location coordinates on event \"#{kind}\": latitude and longitude must be set together")
515
+ end
516
+ return {} unless coordinates.size == 2
517
+
518
+ { "$latitude" => { double_value: coordinates["latitude"] },
519
+ "$longitude" => { double_value: coordinates["longitude"] } }
520
+ end
521
+ end
522
+ end