pug-sh 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 893420d16827c4d7472d599fde6fc5278e354cc559ffcd217b5ff34151c0d5c5
4
- data.tar.gz: d9f76d529e9e0fda1fc678a6096cc9922076e84e0a7c53515e5c6ec98eee4eed
3
+ metadata.gz: e3fa18ab593cd55c417d83dc152eb219e7f7d3d5d6053a84abf426ace5028fde
4
+ data.tar.gz: 9cb0fc72b31991999476f98d1a49a65f707910b7f09020f405ef8fe1e7b81ca6
5
5
  SHA512:
6
- metadata.gz: 9194a40e3a2eb238d5dce9f1db3803ca4fd103a0ae366ef27d8dfd266f93fff923fba12afbbce82796af440175a0563bc39495b4e12e007fe32bb7fbb3fb3540
7
- data.tar.gz: 4db8dc628a8f10582f53eae2242bd6e35c8e1511669cf643e81b1c095615353fcbbc58ca362e058cbbe72bd81eb4f35b886d34cb88c0c8209688f4764b6a7d7f
6
+ metadata.gz: 8076f0a24d015d3c1f40cc233cb77e880f913a5bebf0afa2bccbe7ad52acaaee57932c3e89cc85db2f79d146f2c35f0e5e1acf3285e35cb57a5c2e6b5cf8ceef
7
+ data.tar.gz: a89f4b45abb12e384855e49a142474dcf1c16a0b410fb5b96b6f0fc4a24c30b99388c6b4ac38221113753f641cd1c3392f3b30241871065e6276ae8b5e394412
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pug-sh
6
6
 
7
- Pug server-side SDK for Ruby — event tracking, profile identify, and analytics reads.
7
+ Pug server-side SDK for Ruby: event tracking, profile identify, and analytics reads.
8
8
 
9
9
  It is instance-based and authenticates with a **private** project API key (`prv_…`). Ingestion
10
10
  never raises, so a tracking call can't take down your request path.
@@ -25,13 +25,13 @@ require "pug"
25
25
 
26
26
  pug = Pug::Client.new(api_key: ENV.fetch("PUG_API_KEY"))
27
27
 
28
- # Track an event. distinct_id (who the event is for) comes first — a server has no ambient user.
28
+ # Track an event. distinct_id (who the event is for) comes first, as a server has no ambient user.
29
29
  pug.track("user_42", "order.completed", amount: 49.0, currency: "USD")
30
30
 
31
31
  # Well-known events get their properties type-checked and validated against the event's schema.
32
32
  pug.track("user_42", "feature_used", feature_id: "export")
33
33
 
34
- # Identify a profile (never raises — failures are logged).
34
+ # Identify a profile (never raises; failures are logged).
35
35
  pug.identify("user_42", email: "ada@example.com", plan: "pro")
36
36
 
37
37
  # Reads raise Pug::Error.
@@ -56,9 +56,9 @@ pug.close
56
56
  `max_wait` after the failure, but no sooner than a second; a permanent one (a fixed set of
57
57
  client-error Connect codes, plus any non-transport error) dead-letters them via `on_error`.
58
58
  `close` drains and reports anything still undelivered through `on_error` and the log.
59
- `on_error` covers buffered `track` events only — `identify` failures are logged, and so are
59
+ `on_error` covers buffered `track` events only: `identify` failures are logged, and so are
60
60
  events the server drops from a batch it accepts (`BatchCreateResponse.dropped`), at WARN.
61
- - **Reads are request/response and do raise** `Pug::Error` — they run in your control flow, so
61
+ - **Reads are request/response and do raise** `Pug::Error`: they run in your control flow, so
62
62
  you own retry. They are not auto-retried.
63
63
  - **Well-known events** have typed, validated properties; any other `kind` is accepted as a
64
64
  custom event with loose properties.
@@ -72,7 +72,7 @@ pug.close
72
72
 
73
73
  | Option | Type | Default | Description |
74
74
  | ---------- | ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
75
- | `api_key` | `String` | — | **Required.** Private project key (`prv_…`). |
75
+ | `api_key` | `String` | none | **Required.** Private project key (`prv_…`). |
76
76
  | `endpoint` | `String` | hosted endpoint | Pug server origin. |
77
77
  | `batch` | `Hash` | see below | Ingestion buffer: `max_size` (100, capped at 1000), `max_wait` (5 seconds), `max_queue_size` (10,000). |
78
78
  | `on_error` | callable | none | Dead-letter hook for undeliverable buffered `track` events, called with `(error, events)`. |
@@ -100,13 +100,13 @@ they behave as anywhere else, blocking the thread that called `track`.
100
100
 
101
101
  ### Ingestion (never raises)
102
102
 
103
- - `pug.track(distinct_id, kind, properties = nil, options = nil)` — enqueue an event. Non-blocking.
104
- - `pug.identify(external_id, traits = nil, options = nil)` — create or update a profile. Blocks
103
+ - `pug.track(distinct_id, kind, properties = nil, options = nil)`: enqueue an event. Non-blocking.
104
+ - `pug.identify(external_id, traits = nil, options = nil)`: create or update a profile. Blocks
105
105
  until the server answers.
106
- - `pug.flush(timeout: nil)` — send the currently buffered events now and wait for that attempt.
106
+ - `pug.flush(timeout: nil)`: send the currently buffered events now and wait for that attempt.
107
107
  Returns `false` if `timeout` (seconds) passed first. `true` means the attempt finished, not that
108
108
  every event was delivered: a transient failure keeps them queued for a retry.
109
- - `pug.close(timeout: nil)` — drain and shut down; call on graceful exit so nothing buffered is lost.
109
+ - `pug.close(timeout: nil)`: drain and shut down; call on graceful exit so nothing buffered is lost.
110
110
  Returns `false` if `timeout` passed first. Either way, by the time it returns every event it
111
111
  couldn't send has gone to `on_error` and the log, including a batch still being sent when the
112
112
  timeout ran out, which may yet arrive.
@@ -125,7 +125,7 @@ pug.track("user_42", "signup", { plan: "pro", location: "landing" }, location: {
125
125
  ```
126
126
 
127
127
  `track` options: `timestamp` (a `Time`; defaults to now), `session_id` (a UUID overriding the
128
- client's per-instance session), `location` (the visitor's location — see below).
128
+ client's per-instance session), `location` (the visitor's location; see below).
129
129
  `identify` options, which work the same way with traits: `anonymous_id` (must start with `anon-`;
130
130
  triggers anon→identified merge), `device_id`. Traits must be JSON-serializable, with nested values
131
131
  mapped as for properties below; they travel as a protobuf `Struct`, whose numbers are doubles, so
@@ -150,14 +150,21 @@ becomes an ISO 8601 string and a `BigDecimal` or `Rational` a number, while a va
150
150
  form for, such as a `Set`, drops the whole property with a warning. Strings are capped at 1024
151
151
  bytes (truncated with a warning), and invalid UTF-8 is repaired.
152
152
 
153
+ Keys in snake_case are sent in camelCase, so `order_total:` arrives as `orderTotal`, the spelling
154
+ well-known event fields use and JavaScript code usually writes. Any other key, camelCase or not, is
155
+ sent as written, as are the keys inside a nested `Hash`. Pug's Node, Go and Python SDKs send every
156
+ key as written, so `order_total` from them stays `order_total`. If two keys come out the same, such
157
+ as `order_total` and `orderTotal`, the last one is sent and the other is dropped with a warning, as
158
+ with `Hash#merge`. Trait keys work the same way.
159
+
153
160
  ### Well-known events
154
161
 
155
162
  For a well-known kind such as `purchase`, properties are checked against the event's schema.
156
163
  Keys may be the field's name (`product_id`) or its JSON name (`productId`); either way the
157
- property is sent under the JSON name, like every other Pug SDK. A field's type comes from the
158
- schema rather than the Ruby value, so `amount: 10` is sent as a double. A value of the wrong type,
159
- or one failing the schema's rules, drops the event with an error; other keys ride along as custom
160
- properties.
164
+ property is sent under the JSON name, like every other Pug SDK, but giving both drops the event
165
+ with an error. A field's type comes from the schema rather than the Ruby value, so `amount: 10` is
166
+ sent as a double. A value of the wrong type, or one failing the schema's rules, drops the event
167
+ with an error; other keys ride along as custom properties.
161
168
 
162
169
  ```ruby
163
170
  pug.track("user_42", "purchase", product_id: "p1", amount: 49, currency: "USD", coupon: "FALL")
@@ -169,36 +176,37 @@ Duration fields take seconds as a number, `{ seconds:, nanos: }`, or a `Google::
169
176
  ### Location
170
177
 
171
178
  Pug only derives geo from CDN headers on browser (public-key) requests. This SDK uses a private
172
- key, so the server adds no geo of its own — without `location`, an event has no geo at all:
179
+ key, so the server adds no geo of its own. Without `location`, an event has no geo at all:
173
180
 
174
181
  ```ruby
175
182
  pug.track("user_42", "checkout_completed", { plan: "pro" }, location: { country: "DE", city: "Berlin" })
176
183
  ```
177
184
 
178
185
  Fields: `continent`, `country`, `region`, `city`, `postal_code`, `metro_code`, `timezone`,
179
- `latitude`, `longitude` — strings, except the two coordinates. `country` must be an ISO 3166-1
186
+ `latitude`, `longitude`: strings, except the two coordinates. `country` must be an ISO 3166-1
180
187
  alpha-2 code (case and surrounding spaces are normalized); `latitude` and `longitude` are only
181
188
  sent as a pair.
182
189
 
183
- A field that is missing, nil or empty is omitted silently. One that looks like a mistake —
184
- unknown key, wrong type, unknown country, coordinate out of range — is dropped with a warning and
190
+ A field that is missing, nil or empty is omitted silently. One that looks like a mistake
191
+ (unknown key, wrong type, unknown country, coordinate out of range) is dropped with a warning and
185
192
  the rest is still sent. If nothing is usable the event goes out with no geo.
186
193
 
187
194
  ### Reads (raise `Pug::Error`)
188
195
 
189
196
  These run in your control flow:
190
197
 
191
- - `pug.profiles` — `get(id, include_bots: false)`, `get_by_external_id(external_id, include_bots: false)`,
198
+ - `pug.profiles`: `get(id, include_bots: false)`, `get_by_external_id(external_id, include_bots: false)`,
192
199
  `delete(id)`, `list(request = nil, timeout: nil)` (auto-paginating: yields each profile to a
193
200
  block, or returns an `Enumerator`). The timeout bounds the whole stream, the time your block
194
201
  takes included, so `timeout:` (seconds) can give one long `list` more than the client's.
195
- - `pug.activity` — `feed`, `event_explorer`, `heatmap`, `profile_sessions`, `profile_stats`,
202
+ - `pug.activity`: `feed`, `event_explorer`, `heatmap`, `profile_sessions`, `profile_stats`,
196
203
  `filter_schema`, `property_values`.
197
- - `pug.insights` — `query`, `segment_users`, `filter_schema`, `property_values`.
204
+ - `pug.insights`: `query`, `segment_users`, `filter_schema`, `property_values`.
198
205
 
199
206
  Requests are a Hash or the generated request message, and responses are the generated messages,
200
207
  under `Pug::Proto` (`Pug::Proto::Shared::Insights::V1::QueryResponse`, …); call `to_h` on one
201
- for plain Ruby data.
208
+ for plain Ruby data. Property names are the ones stored, so a property tracked as `order_total:`
209
+ is filtered on, and comes back, as `orderTotal`.
202
210
 
203
211
  ```ruby
204
212
  sessions = pug.activity.profile_sessions(distinct_id: "user_42", page_size: 50)
data/lib/pug/client.rb CHANGED
@@ -239,10 +239,25 @@ module Pug
239
239
  # Struct can't carry, such as NaN. A Struct's numbers are doubles, so an integer past 2^53
240
240
  # would arrive changed.
241
241
  def to_struct(traits)
242
- ready = EventBuilder.json_ready(traits, max_integer: EventBuilder::MAX_SAFE_FLOAT)
242
+ ready = EventBuilder.json_ready(camel_case_keys(traits), max_integer: EventBuilder::MAX_SAFE_FLOAT)
243
243
  ::Google::Protobuf::Struct.from_hash(JSON.parse(JSON.generate(ready)))
244
244
  rescue StandardError => e
245
245
  raise NotSerializable, e.message
246
246
  end
247
+
248
+ # Only the top level, as for properties: a nested Hash is as likely data keyed by ids.
249
+ def camel_case_keys(traits)
250
+ given = {}
251
+ traits.each_with_object({}) do |(key, value), out|
252
+ name = key.to_s
253
+ name = EventBuilder.camel_case(name.encoding.ascii_compatible? ? name : name.encode(Encoding::UTF_8))
254
+ if given.key?(name)
255
+ @log.warn("identify() traits #{given[name].inspect} and #{key.inspect} both go out as #{name.inspect}; " \
256
+ "sending the last")
257
+ end
258
+ given[name] = key
259
+ out[name] = value
260
+ end
261
+ end
247
262
  end
248
263
  end
@@ -30,6 +30,8 @@ module Pug
30
30
  # Past 2^53 a Float may already have been rounded, so it can't stand for an exact integer.
31
31
  MAX_SAFE_FLOAT = 2**53
32
32
 
33
+ SNAKE_CASE = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)+\z/
34
+
33
35
  INTEGER_RANGES = {
34
36
  int32: (-(2**31))..((2**31) - 1),
35
37
  sint32: (-(2**31))..((2**31) - 1),
@@ -66,15 +68,15 @@ module Pug
66
68
  Timestamp = ::Google::Protobuf::Timestamp
67
69
 
68
70
  SchemaField = Struct.new(:descriptor, :name, :json_name, :type)
69
- Schema = Struct.new(:klass, :name, :by_key, :fields)
71
+ Schema = Struct.new(:klass, :name, :by_json_name, :fields)
70
72
 
71
- # Field lookups for every well-known schema, by proto name and JSON name, worked out once.
73
+ # Field lookups for every well-known schema, by JSON name, worked out once.
72
74
  SCHEMAS = WellKnown::CATALOG.transform_values do |klass|
73
75
  fields = klass.descriptor.map do |field|
74
76
  SchemaField.new(field, field.name, field.json_name, field.type).freeze
75
77
  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
+ by_json_name = fields.to_h { |field| [field.json_name, field] }
79
+ Schema.new(klass, klass.descriptor.name, by_json_name.freeze, fields.freeze).freeze
78
80
  end.freeze
79
81
 
80
82
  class FieldError < StandardError
@@ -127,6 +129,13 @@ module Pug
127
129
  end
128
130
  end
129
131
 
132
+ # Protobuf's JSON-name rule, so order_total lands on a well-known field's JSON name. Only
133
+ # lowercase snake_case is renamed, since the full rule would turn ORDER_ID into ORDERID; the
134
+ # ascii_only? check keeps match? from raising on a UTF-16 or broken key.
135
+ def self.camel_case(key)
136
+ key.ascii_only? && key.match?(SNAKE_CASE) ? key.gsub(/_[a-z0-9]/) { |pair| pair[1].upcase } : key
137
+ end
138
+
130
139
  def initialize(log)
131
140
  @log = log
132
141
  end
@@ -213,8 +222,8 @@ module Pug
213
222
  end
214
223
 
215
224
  # 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.
225
+ # double stays a double. A key names a field once camel-cased, so the proto name works as well
226
+ # as the JSON name; unknown keys ride along as custom properties.
218
227
  def well_known_properties(schema, kind, properties)
219
228
  message, extras = typed_message(schema, properties)
220
229
  violations = Validator.validate(message)
@@ -234,9 +243,9 @@ module Pug
234
243
  extras = {}
235
244
  properties.each do |key, value|
236
245
  name = utf8(key.to_s)
237
- field = schema.by_key[name]
246
+ field = schema.by_json_name[self.class.camel_case(name)]
238
247
  if field.nil?
239
- extras[name] = value
248
+ extras[key] = value
240
249
  elsif given.key?(field)
241
250
  raise FieldError.new(field.name, "given twice, as #{given[field].first} and #{name}")
242
251
  else
@@ -373,9 +382,18 @@ module Pug
373
382
  end
374
383
 
375
384
  def custom_properties(properties, kind)
385
+ given = {}
376
386
  properties.each_with_object({}) do |(key, value), out|
387
+ name = self.class.camel_case(utf8(key.to_s))
377
388
  mapped = custom_value(value)
378
- out[utf8(key.to_s)] = mapped if mapped
389
+ next unless mapped
390
+
391
+ if given.key?(name)
392
+ @log.warn("Properties #{given[name].inspect} and #{key.inspect} on event \"#{kind}\" both go out as " \
393
+ "#{name.inspect}; sending the last")
394
+ end
395
+ given[name] = key
396
+ out[name] = mapped
379
397
  rescue NotRepresentable => e
380
398
  @log.warn("Property \"#{key}\" on event \"#{kind}\" not representable (#{e.message}), skipping")
381
399
  end
data/lib/pug/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pug
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pug-sh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - pug.sh