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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +247 -0
- data/lib/pug/batcher.rb +384 -0
- data/lib/pug/client.rb +248 -0
- data/lib/pug/connect.rb +291 -0
- data/lib/pug/countries.rb +19 -0
- data/lib/pug/errors.rb +16 -0
- data/lib/pug/event_builder.rb +522 -0
- data/lib/pug/gen/buf/validate/validate_pb.rb +59 -0
- data/lib/pug/gen/common/events/v1/api_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/app_events_pb.rb +33 -0
- data/lib/pug/gen/common/events/v1/auth_events_pb.rb +32 -0
- data/lib/pug/gen/common/events/v1/billing_events_pb.rb +41 -0
- data/lib/pug/gen/common/events/v1/chat_events_pb.rb +56 -0
- data/lib/pug/gen/common/events/v1/commerce_events_pb.rb +38 -0
- data/lib/pug/gen/common/events/v1/discovery_events_pb.rb +31 -0
- data/lib/pug/gen/common/events/v1/error_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/file_events_pb.rb +29 -0
- data/lib/pug/gen/common/events/v1/form_events_pb.rb +27 -0
- data/lib/pug/gen/common/events/v1/integration_events_pb.rb +27 -0
- data/lib/pug/gen/common/events/v1/invitation_events_pb.rb +26 -0
- data/lib/pug/gen/common/events/v1/media_events_pb.rb +36 -0
- data/lib/pug/gen/common/events/v1/navigation_events_pb.rb +31 -0
- data/lib/pug/gen/common/events/v1/notification_events_pb.rb +28 -0
- data/lib/pug/gen/common/events/v1/options_pb.rb +25 -0
- data/lib/pug/gen/common/events/v1/social_events_pb.rb +25 -0
- data/lib/pug/gen/common/events/v1/support_events_pb.rb +33 -0
- data/lib/pug/gen/common/events/v1/workspace_events_pb.rb +30 -0
- data/lib/pug/gen/common/v1/filter_schema_pb.rb +29 -0
- data/lib/pug/gen/common/v1/filters_pb.rb +27 -0
- data/lib/pug/gen/common/v1/property_value_pb.rb +24 -0
- data/lib/pug/gen/common/v1/time_pb.rb +25 -0
- data/lib/pug/gen/sdk/devices/v1/devices_pb.rb +35 -0
- data/lib/pug/gen/sdk/events/v1/events_pb.rb +30 -0
- data/lib/pug/gen/sdk/profiles/v1/profiles_pb.rb +28 -0
- data/lib/pug/gen/shared/activity/v1/activity_pb.rb +46 -0
- data/lib/pug/gen/shared/insights/v1/insights_pb.rb +69 -0
- data/lib/pug/gen/shared/profiles/v1/profiles_pb.rb +43 -0
- data/lib/pug/log.rb +35 -0
- data/lib/pug/proto.rb +22 -0
- data/lib/pug/reads.rb +118 -0
- data/lib/pug/rpc.rb +73 -0
- data/lib/pug/validator.rb +475 -0
- data/lib/pug/version.rb +5 -0
- data/lib/pug/well_known.rb +33 -0
- data/lib/pug-sh.rb +3 -0
- data/lib/pug.rb +5 -0
- 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
|