pug-sh 0.0.2 → 0.1.0

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: e3fa18ab593cd55c417d83dc152eb219e7f7d3d5d6053a84abf426ace5028fde
4
- data.tar.gz: 9cb0fc72b31991999476f98d1a49a65f707910b7f09020f405ef8fe1e7b81ca6
3
+ metadata.gz: 8a103a225401b6c59dc03d3c909304b4aea17c4fd8ec267988d0a1cbb09c3ae5
4
+ data.tar.gz: 39341542bf5cd3e99b453ad057e09207ad4b08023610037998b6956106696806
5
5
  SHA512:
6
- metadata.gz: 8076f0a24d015d3c1f40cc233cb77e880f913a5bebf0afa2bccbe7ad52acaaee57932c3e89cc85db2f79d146f2c35f0e5e1acf3285e35cb57a5c2e6b5cf8ceef
7
- data.tar.gz: a89f4b45abb12e384855e49a142474dcf1c16a0b410fb5b96b6f0fc4a24c30b99388c6b4ac38221113753f641cd1c3392f3b30241871065e6276ae8b5e394412
6
+ metadata.gz: d2d303d9898809f07c153190bed37ef463261b451ea3c5450d923000f45788b1375864db9d0b3fa035effe6b7d4e8bd1fb79b2616f2ebbb44fae94cb6adc0632
7
+ data.tar.gz: 00b493cae5fcd45e1645f62b05c46284faa48cf0866549c4777e412c10c5c9b2bc6025e4375551c0b1011ef4c57b6a64807ffac1105466af564c691b352c658f
data/README.md CHANGED
@@ -31,7 +31,7 @@ pug.track("user_42", "order.completed", amount: 49.0, currency: "USD")
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. Like track, it returns at once: identifies are queued and sent in batches.
35
35
  pug.identify("user_42", email: "ada@example.com", plan: "pro")
36
36
 
37
37
  # Reads raise Pug::Error.
@@ -43,73 +43,80 @@ trend = pug.insights.query(
43
43
  granularity: :GRANULARITY_DAY,
44
44
  )
45
45
 
46
- # Drain on shutdown so no buffered events are lost.
46
+ # Drain on shutdown so no queued events or identifies are lost.
47
47
  pug.close
48
48
  ```
49
49
 
50
50
  ## Behaviour
51
51
 
52
52
  - **Ingestion never raises.** `track` and `identify` validate input client-side and log and drop
53
- anything invalid. `track` is non-blocking: a background thread sends events in batches, by size
54
- (`max_size`) or interval (`max_wait`).
55
- - **Delivery is best-effort with retry.** A transient send failure keeps events queued and retries
56
- `max_wait` after the failure, but no sooner than a second; a permanent one (a fixed set of
57
- client-error Connect codes, plus any non-transport error) dead-letters them via `on_error`.
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
60
- events the server drops from a batch it accepts (`BatchCreateResponse.dropped`), at WARN.
53
+ anything invalid. Both are non-blocking: background threads, one for events and one for
54
+ identifies, send them in batches, by size (`max_size`) or interval (`max_wait`). Identifies go
55
+ out with `BatchIdentify`; a server that predates it gets one `Identify` call each.
56
+ - **Delivery is best-effort with retry.** A transient send failure keeps events and identifies
57
+ queued and retries `max_wait` after the failure, but no sooner than a second; a permanent one (a
58
+ fixed set of client-error Connect codes, plus any non-transport error) dead-letters them via
59
+ `on_error`. `close` drains and reports anything still undelivered through `on_error` and the log.
60
+ Events the server drops from a batch it accepts (`BatchCreateResponse.dropped`) are logged at
61
+ WARN.
61
62
  - **Reads are request/response and do raise** `Pug::Error`: they run in your control flow, so
62
63
  you own retry. They are not auto-retried.
63
64
  - **Well-known events** have typed, validated properties; any other `kind` is accepted as a
64
65
  custom event with loose properties.
65
66
  - **Thread- and fork-safe.** One client can be shared by every thread. A client created before a
66
- fork (Puma, Unicorn, Sidekiq) starts empty in each child, with its own sender thread and
67
- connections; events buffered in the parent stay with the parent.
67
+ fork (Puma, Unicorn, Sidekiq) starts empty in each child, with its own sender threads and
68
+ connections; events and identifies queued in the parent stay with the parent.
68
69
 
69
70
  ## API
70
71
 
71
72
  ### `Pug::Client.new(**options)`
72
73
 
73
- | Option | Type | Default | Description |
74
- | ---------- | ------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
75
- | `api_key` | `String` | none | **Required.** Private project key (`prv_…`). |
76
- | `endpoint` | `String` | hosted endpoint | Pug server origin. |
77
- | `batch` | `Hash` | see below | Ingestion buffer: `max_size` (100, capped at 1000), `max_wait` (5 seconds), `max_queue_size` (10,000). |
78
- | `on_error` | callable | none | Dead-letter hook for undeliverable buffered `track` events, called with `(error, events)`. |
79
- | `logger` | `Logger`-like | `Logger.new($stderr)`, WARN | Gets the SDK's warnings and errors: anything with public `warn` and `error`, such as `Rails.logger` (wrap an IO in `Logger.new`). |
80
- | `timeout` | `Numeric` | 10 | Request timeout, in seconds. |
74
+ | Option | Type | Default | Description |
75
+ | ---------- | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `api_key` | `String` | none | **Required.** Private project key (`prv_…`). |
77
+ | `endpoint` | `String` | hosted endpoint | Pug server origin. |
78
+ | `batch` | `Hash` | see below | Ingestion buffers, one each for events and identifies: `max_size` (100, capped at 1000), `max_wait` (5 seconds), `max_queue_size` (10,000). |
79
+ | `on_error` | callable | none | Dead-letter hook for queued events and identifies that couldn't be delivered, called with `(error, undelivered)`. |
80
+ | `logger` | `Logger`-like | `Logger.new($stderr)`, WARN | Gets the SDK's warnings and errors: anything with public `warn` and `error`, such as `Rails.logger` (wrap an IO in `Logger.new`). |
81
+ | `timeout` | `Numeric` | 10 | Request timeout, in seconds. |
81
82
 
82
83
  ```ruby
83
84
  Pug::Client.new(
84
85
  api_key: ENV.fetch("PUG_API_KEY"),
85
86
  batch: { max_size: 100, max_wait: 5, max_queue_size: 10_000 },
86
- on_error: lambda do |error, events|
87
- # dead-letter sink: error is a Pug::QueueOverflowError, a Pug::ClosedError, a Pug::Error
88
- # with a permanent Connect code, the transient Pug::Error that failed close's last attempt,
89
- # or the exception a bug raised
87
+ on_error: lambda do |error, undelivered|
88
+ # dead-letter sink for undelivered.events and undelivered.identifies, one of them empty:
89
+ # error is a Pug::QueueOverflowError, a Pug::ClosedError, a Pug::Error with a permanent
90
+ # Connect code, the transient Pug::Error that failed close's last attempt, or the exception a
91
+ # bug raised
90
92
  end,
91
93
  )
92
94
  ```
93
95
 
94
- `on_error` runs on the background sender thread; on the thread calling `close`, for what it
95
- reports undelivered; or, when the queue overflows, on the thread that called `track`. It must be
96
- thread-safe and return promptly. Called from inside it on the sender or the closing thread, `flush`
97
- and `close` don't wait for what is waiting on `on_error`: they return `false` at once, except that
98
- `flush` returns `true` while `close` reports. On the overflow path nothing waits on `on_error`, so
99
- they behave as anywhere else, blocking the thread that called `track`.
96
+ `undelivered` is a `Pug::Undelivered`, whose `events` are `Pug::Proto::Sdk::Events::V1::Event`
97
+ messages and whose `identifies` are the `Pug::Proto::Sdk::Profiles::V1::IdentifyRequest` messages
98
+ `identify` queued.
99
+
100
+ `on_error` runs on a background sender thread; on the thread calling `close`, for what it reports
101
+ undelivered; or, when a queue overflows, on the thread that called `track` or `identify`. It must
102
+ be thread-safe and return promptly. Called from inside it on a sender thread or the closing thread,
103
+ `flush` and `close` return at once rather than wait, since what they would wait for may itself be
104
+ waiting on `on_error`: `close` returns `false`, and `flush` returns `false` unless it found nothing
105
+ to wait for. On the overflow path nothing waits on `on_error`, so they behave as anywhere else,
106
+ blocking the thread that called `track` or `identify`.
100
107
 
101
108
  ### Ingestion (never raises)
102
109
 
103
110
  - `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
- until the server answers.
106
- - `pug.flush(timeout: nil)`: send the currently buffered events now and wait for that attempt.
111
+ - `pug.identify(external_id, traits = nil, options = nil)`: enqueue a create-or-update of a
112
+ profile; its traits are shallow-merged into the stored ones. Non-blocking.
113
+ - `pug.flush(timeout: nil)`: send the queued events and identifies now and wait for that attempt.
107
114
  Returns `false` if `timeout` (seconds) passed first. `true` means the attempt finished, not that
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.
110
- Returns `false` if `timeout` passed first. Either way, by the time it returns every event it
111
- couldn't send has gone to `on_error` and the log, including a batch still being sent when the
112
- timeout ran out, which may yet arrive.
115
+ everything was delivered: a transient failure keeps it queued for a retry.
116
+ - `pug.close(timeout: nil)`: drain and shut down; call on graceful exit so nothing queued is lost.
117
+ Returns `false` if `timeout` passed first. Either way, by the time it returns every event and
118
+ identify it couldn't send has gone to `on_error` and the log, including a batch still being sent
119
+ when the timeout ran out, which may yet arrive.
113
120
 
114
121
  A `timeout` that isn't a number waits without one, with a warning.
115
122
 
@@ -125,11 +132,14 @@ pug.track("user_42", "signup", { plan: "pro", location: "landing" }, location: {
125
132
  ```
126
133
 
127
134
  `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).
135
+ client's per-instance session; any other value is ignored with a warning), `location` (the
136
+ visitor's location; see below).
129
137
  `identify` options, which work the same way with traits: `anonymous_id` (must start with `anon-`;
130
138
  triggers anon→identified merge), `device_id`. Traits must be JSON-serializable, with nested values
131
139
  mapped as for properties below; they travel as a protobuf `Struct`, whose numbers are doubles, so
132
- an integer past 2^53 is refused.
140
+ an integer past 2^53 is refused. A trait holding NaN or Infinity, at any depth, is skipped with a
141
+ warning, and the rest of the identify still goes out. An identify over 900 KiB is dropped with an
142
+ error: the server can't deliver one that large.
133
143
 
134
144
  Custom properties map by Ruby type:
135
145
 
data/lib/pug/batcher.rb CHANGED
@@ -4,55 +4,97 @@ require_relative "errors"
4
4
  require_relative "log"
5
5
 
6
6
  module Pug
7
- # In-memory batching transport. Events accumulate and are sent by a single background thread
8
- # on a size or interval trigger, so batches never overlap and go out in order. A transient
9
- # failure re-queues them for a retry after max_wait; a permanent one dead-letters them to
10
- # on_error, as does close for anything still undelivered.
7
+ # One sender thread per queue, so batches never overlap and go out in order.
11
8
  class Batcher
12
9
  DEFAULTS = { max_size: 100, max_wait: 5.0, max_queue_size: 10_000 }.freeze
13
10
 
14
- # BatchCreateRequest.events is capped at 1000 by the events proto.
15
- MAX_BATCH_EVENTS = 1000
11
+ # The proto cap on BatchCreateRequest.events and BatchIdentifyRequest.identifies.
12
+ MAX_BATCH_ITEMS = 1000
16
13
 
17
14
  # The server refuses request bodies over 8 MiB with resource_exhausted, a transient code, so
18
15
  # an oversized batch would be retried forever. Half the cap leaves room for the envelope.
19
16
  MAX_BATCH_BYTES = 4 * 1024 * 1024
20
17
 
21
- # Retries wait max_wait but at least this long, or a max_wait of 0 would retry a server that
22
- # refuses connections in a tight loop.
18
+ # Else a max_wait of 0 would retry a refusing server in a tight loop.
23
19
  MIN_RETRY_WAIT = 1
24
20
 
25
- # Retrying can't fix these client errors, nor an error that didn't come from the RPC layer,
26
- # which is a bug.
21
+ # Plus any error not from the RPC layer, which is a bug.
27
22
  PERMANENT_CODES = %i[invalid_argument not_found already_exists permission_denied failed_precondition
28
23
  unimplemented unauthenticated].freeze
29
24
 
30
- Item = Struct.new(:event, :bytesize)
25
+ Noun = Data.define(:one, :many) do
26
+ def word(number) = number == 1 ? one : many
27
+
28
+ def count(number) = "#{number} #{word(number)}"
29
+ end
30
+ EVENTS = Noun.new(one: "event", many: "events")
31
+ IDENTIFIES = Noun.new(one: "identify", many: "identifies")
32
+
33
+ Item = Struct.new(:message, :bytesize)
34
+
35
+ # Lets a sink say the first `sent` items arrived, so only the rest is retried.
36
+ class PartialSendError < StandardError
37
+ attr_reader :sent
38
+
39
+ def initialize(sent)
40
+ @sent = sent
41
+ super("failed after delivering #{sent}")
42
+ end
43
+ end
31
44
 
32
45
  def self.permanent?(error)
33
46
  !error.is_a?(Error) || error.code.nil? || PERMANENT_CODES.include?(error.code)
34
47
  end
35
48
 
49
+ # Idempotent and silent on a resolved config, so a client resolves once for both batchers.
50
+ def self.resolve(config, log)
51
+ config = (config || {}).transform_keys(&:to_sym)
52
+ (config.keys - DEFAULTS.keys).each { |key| log.warn("Ignoring unknown batch option #{key.inspect}") }
53
+ settings = DEFAULTS.merge(config)
54
+ max_size = setting(log, :max_size, settings[:max_size], 1)
55
+ if max_size > MAX_BATCH_ITEMS
56
+ log.warn("batch.max_size exceeds the #{MAX_BATCH_ITEMS}-item proto cap, clamping to #{MAX_BATCH_ITEMS}.")
57
+ max_size = MAX_BATCH_ITEMS
58
+ end
59
+ { max_size: max_size, max_wait: setting(log, :max_wait, settings[:max_wait], 0),
60
+ max_queue_size: setting(log, :max_queue_size, settings[:max_queue_size], 1) }
61
+ end
62
+
63
+ def self.setting(log, name, value, min)
64
+ if min >= 1 && !value.is_a?(Integer)
65
+ log.warn("batch.#{name} must be an integer, using default.")
66
+ return DEFAULTS[name]
67
+ end
68
+ unless value.is_a?(Numeric) && value.real? && value.finite? && value >= min
69
+ log.warn("batch.#{name} must be >= #{min}, using default.")
70
+ return DEFAULTS[name]
71
+ end
72
+ value
73
+ end
74
+ private_class_method :setting
75
+
36
76
  attr_reader :max_size, :max_wait, :max_queue_size
37
77
 
38
- def initialize(sink, config, on_error, log)
78
+ def initialize(sink, config, on_error, log, noun: EVENTS)
39
79
  @sink = sink
40
80
  @on_error = on_error
41
81
  @log = log
42
- configure(config)
82
+ @noun = noun
83
+ @max_size, @max_wait, @max_queue_size = self.class.resolve(config, log).values_at(*DEFAULTS.keys)
84
+ @retry_wait = [@max_wait, MIN_RETRY_WAIT].max
43
85
  @lock = Mutex.new
44
86
  @closed = false
45
87
  @closer = nil
46
88
  reset
47
89
  end
48
90
 
49
- def enqueue(event, bytesize)
91
+ def enqueue(message, bytesize)
50
92
  dropped = nil
51
93
  closed = @lock.synchronize do
52
94
  after_fork
53
95
  next true if @closed
54
96
 
55
- @buffer << Item.new(event, bytesize)
97
+ @buffer << Item.new(message, bytesize)
56
98
  dropped = trim
57
99
  arm
58
100
  @kick = true if @buffer.size >= @max_size && !@backoff
@@ -60,34 +102,49 @@ module Pug
60
102
  @wake.signal
61
103
  false
62
104
  end
63
- return dead_letter(ClosedError.new("client is closed"), [event]) if closed
105
+ return dead_letter(ClosedError.new("client is closed"), [message]) if closed
64
106
 
65
107
  report_overflow(dropped)
66
108
  end
67
109
 
68
- # Resolves once the attempt it requested has finished, or returns false when timeout (in
69
- # seconds) runs out first. A transient failure keeps events queued, so an empty queue is not
70
- # guaranteed; close drains.
110
+ # A transient failure keeps items queued, so this doesn't guarantee an empty queue; close does.
71
111
  def flush(timeout: nil)
112
+ !request_flush.pop(timeout: timeout).nil?
113
+ end
114
+
115
+ # The Queue yields nil at once if the attempt can't be waited for.
116
+ def request_flush
72
117
  waiter = Queue.new
73
118
  @lock.synchronize do
74
119
  after_fork
75
- return true if @stopping || (@buffer.empty? && !@sending && @requests.empty?)
120
+ return waiter << true if @stopping || (@buffer.empty? && !@sending && @requests.empty?)
76
121
  # Called from on_error, which runs on the sender: waiting for it would wait forever.
77
- return false if Thread.current.equal?(@thread)
122
+ return waiter.close if sender?
78
123
 
79
124
  @requests << waiter
80
125
  start
81
126
  @wake.signal
82
127
  end
83
- !waiter.pop(timeout: timeout).nil?
128
+ waiter
84
129
  end
85
130
 
86
- # Drains the buffer and stops the sender. Returns false when timeout (in seconds) runs out
87
- # first; everything still undelivered then is dead-lettered before it returns, including a
88
- # batch whose send goes on in the background and may yet succeed.
131
+ def sender?
132
+ Thread.current.equal?(@thread)
133
+ end
134
+
135
+ def closing?
136
+ Thread.current.equal?(@closer) && !@done.closed?
137
+ end
138
+
139
+ # On timeout, everything undelivered is dead-lettered, including a batch still being sent that
140
+ # may yet arrive.
89
141
  def close(timeout: nil)
90
142
  deadline = timeout && (monotonic + timeout)
143
+ finish_close(start_close, deadline)
144
+ end
145
+
146
+ # Split from finish_close so the client can start both drains before waiting on either.
147
+ def start_close
91
148
  first = @lock.synchronize do
92
149
  after_fork
93
150
  next false if @closed
@@ -97,15 +154,20 @@ module Pug
97
154
  disarm
98
155
  true
99
156
  end
157
+ @drain = request_flush if first
158
+ first
159
+ end
160
+
161
+ def finish_close(first, deadline)
100
162
  unless first
101
163
  # Called from on_error, on the sender or the closing thread: the close underway waits on this call.
102
- return false if !@done.closed? && (Thread.current.equal?(@thread) || Thread.current.equal?(@closer))
164
+ return false if !@done.closed? && (sender? || Thread.current.equal?(@closer))
103
165
 
104
166
  @done.pop(timeout: remaining(deadline))
105
167
  return @done.closed? && @drained
106
168
  end
107
169
 
108
- drained = flush(timeout: remaining(deadline))
170
+ drained = !@drain.pop(timeout: remaining(deadline)).nil?
109
171
  thread = @lock.synchronize do
110
172
  @stopping = true
111
173
  @wake.signal
@@ -118,38 +180,11 @@ module Pug
118
180
  @drained
119
181
  end
120
182
 
121
- # The default would list every buffered event.
183
+ # The default would list every buffered item.
122
184
  def inspect = "#<#{self.class.name}>"
123
185
 
124
186
  private
125
187
 
126
- def configure(config)
127
- config = (config || {}).transform_keys(&:to_sym)
128
- (config.keys - DEFAULTS.keys).each { |key| @log.warn("Ignoring unknown batch option #{key.inspect}") }
129
- settings = DEFAULTS.merge(config)
130
- @max_size = setting(:max_size, settings[:max_size], 1)
131
- if @max_size > MAX_BATCH_EVENTS
132
- @log.warn("batch.max_size exceeds the #{MAX_BATCH_EVENTS}-event proto cap, clamping to #{MAX_BATCH_EVENTS}.")
133
- @max_size = MAX_BATCH_EVENTS
134
- end
135
- @max_wait = setting(:max_wait, settings[:max_wait], 0)
136
- @retry_wait = [@max_wait, MIN_RETRY_WAIT].max
137
- @max_queue_size = setting(:max_queue_size, settings[:max_queue_size], 1)
138
- end
139
-
140
- # Counts must be integers; max_wait, in seconds, may be fractional.
141
- def setting(name, value, min)
142
- if min >= 1 && !value.is_a?(Integer)
143
- @log.warn("batch.#{name} must be an integer, using default.")
144
- return DEFAULTS[name]
145
- end
146
- unless value.is_a?(Numeric) && value.real? && value.finite? && value >= min
147
- @log.warn("batch.#{name} must be >= #{min}, using default.")
148
- return DEFAULTS[name]
149
- end
150
- value
151
- end
152
-
153
188
  def reset
154
189
  @buffer = []
155
190
  @inflight = nil
@@ -169,7 +204,7 @@ module Pug
169
204
  end
170
205
 
171
206
  # A forked child inherits the buffer but not the sender thread. The parent still owns and
172
- # sends those events, so the child starts empty with a sender of its own.
207
+ # sends those items, so the child starts empty with a sender of its own.
173
208
  def after_fork
174
209
  reset unless @pid == Process.pid
175
210
  end
@@ -178,7 +213,7 @@ module Pug
178
213
  return if @thread&.alive?
179
214
 
180
215
  @thread = Thread.new { run }
181
- @thread.name = "pug-batcher"
216
+ @thread.name = "pug-#{@noun.many}"
182
217
  @thread.report_on_exception = false
183
218
  end
184
219
 
@@ -195,7 +230,8 @@ module Pug
195
230
  end
196
231
  rescue Exception => e # rubocop:disable Lint/RescueException
197
232
  # Nothing else would report it: the thread doesn't, and on_error or a logger may raise anything.
198
- @log.error("Event sender stopped unexpectedly; it restarts with the next event: #{Log.describe(e)}")
233
+ @log.error("#{@noun.one.capitalize} sender stopped unexpectedly; it restarts with the next #{@noun.one}: " \
234
+ "#{Log.describe(e)}")
199
235
  ensure
200
236
  # An idle exit has already handed over to whichever thread starts next, so only a sender
201
237
  # that is stopping or crashed answers the pending flushes.
@@ -220,15 +256,14 @@ module Pug
220
256
  end
221
257
  if @kick
222
258
  @kick = false
223
- # Stale if the send that set it off has since taken those events, or if a backoff began;
259
+ # Stale if the send that set it off has since taken those items, or if a backoff began;
224
260
  # once closed, close's flush sends.
225
261
  return [] if @buffer.size >= @max_size && !@backoff && !@closed
226
262
 
227
263
  next
228
264
  end
229
265
  if @deadline.nil?
230
- # Idle: nothing is buffered without a timer armed, so the thread exits rather than
231
- # holding on to the client, and the next event starts a new one.
266
+ # Idle: exit rather than hold on to the client; the next item starts a new thread.
232
267
  @thread = nil
233
268
  return
234
269
  end
@@ -239,10 +274,8 @@ module Pug
239
274
  end
240
275
  end
241
276
 
242
- # One attempt at what is buffered now, a chunk at a time. Events stay in the buffer until
243
- # their chunk is taken, and the chunk being sent is tracked, so a close that runs out of time
244
- # can report every event. Stops at the first transient failure, since the rest would likely
245
- # fail the same way.
277
+ # The in-flight chunk is tracked so a timed-out close can report it. Stops at the first
278
+ # transient failure, since the rest would likely fail the same way.
246
279
  def send_buffer
247
280
  pending = @lock.synchronize do
248
281
  disarm
@@ -261,8 +294,7 @@ module Pug
261
294
  @lock.synchronize { @sending = false }
262
295
  end
263
296
 
264
- # Called under the lock: the oldest events, at most limit of them, cut at max_size and
265
- # MAX_BATCH_BYTES.
297
+ # Called under the lock.
266
298
  def take_chunk(limit)
267
299
  cap = [limit, @max_size].min
268
300
  count = 0
@@ -279,15 +311,18 @@ module Pug
279
311
  # Not just StandardError: one that ended the thread would strand the chunk. Any error that
280
312
  # didn't come from the RPC layer is permanent, so the chunk is dead-lettered.
281
313
  def send_chunk(chunk)
282
- @sink.call(chunk.map(&:event))
314
+ @sink.call(chunk.map(&:message))
283
315
  nil
284
316
  rescue Exception => e # rubocop:disable Lint/RescueException
285
317
  e
286
318
  end
287
319
 
288
- # Returns the outcome of a chunk's send. Open, a transient failure re-queues the chunk for a
289
- # retry; closed, it dead-letters it and leaves the rest for close to report.
290
320
  def settle(chunk, error)
321
+ undelivered = chunk
322
+ if error.is_a?(PartialSendError)
323
+ undelivered = chunk.drop(error.sent)
324
+ error = error.cause || error
325
+ end
291
326
  outcome, dropped = @lock.synchronize do
292
327
  # close ran out of time during the send, and has reported the chunk itself.
293
328
  next [:reported] unless @inflight.equal?(chunk)
@@ -296,19 +331,20 @@ module Pug
296
331
  if error.nil? then [:sent]
297
332
  elsif self.class.permanent?(error) then [:permanent]
298
333
  elsif @closed then [:closed]
299
- else [:retry, requeue(chunk)]
334
+ else [:retry, requeue(undelivered)]
300
335
  end
301
336
  end
302
- events = chunk.map(&:event)
337
+ messages = undelivered.map(&:message)
303
338
  case outcome
304
339
  when :permanent
305
- @log.error("Permanent error, #{events.size} event(s) dropped (will NOT retry): #{Log.describe(error)}")
306
- dead_letter(error, events)
340
+ @log.error("Permanent error, #{@noun.count(messages.size)} dropped (will NOT retry): #{Log.describe(error)}")
341
+ dead_letter(error, messages)
307
342
  when :closed
308
- @log.error("Client closed, #{events.size} event(s) dropped after transient error: #{Log.describe(error)}")
309
- dead_letter(error, events)
343
+ @log.error("Client closed, #{@noun.count(messages.size)} dropped after transient error: " \
344
+ "#{Log.describe(error)}")
345
+ dead_letter(error, messages)
310
346
  when :retry
311
- @log.warn("Transient error sending batch, will retry: #{Log.describe(error)}")
347
+ @log.warn("Transient error sending #{@noun.count(messages.size)}, will retry: #{Log.describe(error)}")
312
348
  report_overflow(dropped)
313
349
  end
314
350
  outcome
@@ -316,25 +352,26 @@ module Pug
316
352
 
317
353
  # Called under the lock. The retry is timed from the failure: a timer an enqueue armed during
318
354
  # the send could already be due.
319
- def requeue(chunk)
320
- @buffer.unshift(*chunk)
355
+ def requeue(items)
356
+ @buffer.unshift(*items)
321
357
  @backoff = true
322
358
  disarm
323
359
  arm(@retry_wait)
324
360
  trim
325
361
  end
326
362
 
327
- # Called under the lock. Drops the oldest events past max_queue_size and returns them.
363
+ # Called under the lock.
328
364
  def trim
329
365
  over = @buffer.size - @max_queue_size
330
- @buffer.shift(over).map(&:event) if over.positive?
366
+ @buffer.shift(over).map(&:message) if over.positive?
331
367
  end
332
368
 
333
369
  def report_overflow(dropped)
334
370
  return unless dropped
335
371
 
336
- @log.warn("Queue full, dropping #{dropped.size} oldest event(s)")
337
- dead_letter(QueueOverflowError.new("queue overflow: dropped oldest event"), dropped)
372
+ oldest = "#{dropped.size} oldest #{@noun.word(dropped.size)}"
373
+ @log.warn("Queue full, dropping #{oldest}")
374
+ dead_letter(QueueOverflowError.new("queue overflow: dropped #{oldest}"), dropped)
338
375
  end
339
376
 
340
377
  def report_undelivered
@@ -344,14 +381,15 @@ module Pug
344
381
  taken
345
382
  end
346
383
  if inflight
347
- @log.error("Close timed out with #{inflight.size} event(s) still being sent, which may yet arrive")
348
- dead_letter(ClosedError.new("close timed out with #{inflight.size} event(s) still being sent"),
349
- inflight.map(&:event))
384
+ sending = "#{@noun.count(inflight.size)} still being sent"
385
+ @log.error("Close timed out with #{sending}, which may yet arrive")
386
+ dead_letter(ClosedError.new("close timed out with #{sending}"), inflight.map(&:message))
350
387
  end
351
388
  return if unsent.empty?
352
389
 
353
- @log.error("Client closed with #{unsent.size} undelivered event(s), dropped")
354
- dead_letter(ClosedError.new("client closed with #{unsent.size} undelivered event(s)"), unsent.map(&:event))
390
+ undelivered = "#{unsent.size} undelivered #{@noun.word(unsent.size)}"
391
+ @log.error("Client closed with #{undelivered}, dropped")
392
+ dead_letter(ClosedError.new("client closed with #{undelivered}"), unsent.map(&:message))
355
393
  end
356
394
 
357
395
  def arm(wait = @max_wait)
@@ -362,14 +400,13 @@ module Pug
362
400
  @deadline = nil
363
401
  end
364
402
 
365
- # A raising callback must not abort the sender or a drain: the remaining chunks would be lost
366
- # with neither a log nor a callback. NotImplementedError, a common placeholder, is a
367
- # ScriptError.
368
- def dead_letter(error, events)
369
- @on_error&.call(error, events)
403
+ # A raising callback must not abort the sender or a drain. NotImplementedError, a common
404
+ # placeholder, is a ScriptError.
405
+ def dead_letter(error, messages)
406
+ @on_error&.call(error, messages)
370
407
  nil
371
408
  rescue StandardError, ScriptError => e
372
- @log.error("on_error callback raised; #{events.size} event(s) may be lost: #{Log.describe(e)}")
409
+ @log.error("on_error callback raised; #{@noun.count(messages.size)} may be lost: #{Log.describe(e)}")
373
410
  nil
374
411
  end
375
412
 
data/lib/pug/client.rb CHANGED
@@ -13,6 +13,11 @@ require_relative "rpc"
13
13
  require_relative "validator"
14
14
 
15
15
  module Pug
16
+ # What one failure lost, as on_error receives it. Either list may be empty.
17
+ Undelivered = Data.define(:events, :identifies) do
18
+ def initialize(events: [], identifies: []) = super
19
+ end
20
+
16
21
  class Client
17
22
  DEFAULT_ENDPOINT = "https://api.pugs.dev"
18
23
  DEFAULT_TIMEOUT = 10
@@ -20,6 +25,9 @@ module Pug
20
25
  TRACK_OPTIONS = %w[timestamp session_id location].freeze
21
26
  IDENTIFY_OPTIONS = %w[anonymous_id device_id].freeze
22
27
 
28
+ # Each identify is one NATS message, capped at 1 MiB; the margin covers envelope and headers.
29
+ MAX_IDENTIFY_BYTES = 900 * 1024
30
+
23
31
  class NotSerializable < StandardError; end
24
32
  OMITTED = Object.new.freeze
25
33
  private_constant :NotSerializable, :OMITTED
@@ -39,7 +47,7 @@ module Pug
39
47
  end
40
48
  raise ArgumentError, "[Pug SDK] on_error must be callable" unless on_error.nil? || on_error.respond_to?(:call)
41
49
  unless on_error.nil? || takes_two?(on_error)
42
- raise ArgumentError, "[Pug SDK] on_error must take two arguments, (error, events)"
50
+ raise ArgumentError, "[Pug SDK] on_error must take two arguments, (error, undelivered)"
43
51
  end
44
52
  raise ArgumentError, "[Pug SDK] batch must be a Hash" unless batch.nil? || batch.is_a?(Hash)
45
53
  unless logger.nil? || %i[warn error].all? { |level| logger.respond_to?(level) }
@@ -56,7 +64,14 @@ module Pug
56
64
  @connect = Connect.new(endpoint, api_key, timeout: resolve_timeout(timeout))
57
65
  @rpc = RPC.new(@connect)
58
66
  @events = EventBuilder.new(@log)
59
- @batcher = Batcher.new(method(:send_batch), batch, on_error, @log)
67
+ # Resolved once, so a bad setting warns once rather than once per queue.
68
+ batch = Batcher.resolve(batch, @log)
69
+ on_events = on_error && ->(error, events) { on_error.call(error, Undelivered.new(events:)) }
70
+ on_identifies = on_error && ->(error, identifies) { on_error.call(error, Undelivered.new(identifies:)) }
71
+ @event_queue = Batcher.new(method(:send_events), batch, on_events, @log)
72
+ @identify_queue = Batcher.new(method(:send_identifies), batch, on_identifies, @log, noun: Batcher::IDENTIFIES)
73
+ @close_lock = Mutex.new
74
+ @unbatched = false
60
75
  @session_id = SecureRandom.uuid_v7
61
76
  @closed = false
62
77
  @profiles = Profiles.new(@rpc)
@@ -78,13 +93,9 @@ module Pug
78
93
  properties, options = split_options(properties, options, keywords, TRACK_OPTIONS, "track")
79
94
  return @log.error("track() properties must be a Hash.") unless properties.nil? || properties.is_a?(Hash)
80
95
 
81
- session_id = options["session_id"]
82
- return @log.error("track() session_id must be a String.") unless session_id.nil? || session_id.is_a?(String)
83
-
84
- session_id = @session_id if session_id.nil? || session_id.empty?
85
- event, bytesize = @events.build(kind, session_id, distinct_id, properties,
96
+ event, bytesize = @events.build(kind, session(options["session_id"]), distinct_id, properties,
86
97
  timestamp: options["timestamp"], location: options["location"])
87
- @batcher.enqueue(event, bytesize) if event
98
+ @event_queue.enqueue(event, bytesize) if event
88
99
  nil
89
100
  rescue StandardError => e
90
101
  @log.error("Unexpected error in track(#{kind.inspect}): #{Log.describe(e)}")
@@ -105,31 +116,42 @@ module Pug
105
116
  violations = Validator.validate(request)
106
117
  return @log.error("Invalid identify request: #{Validator.format(violations)}") unless violations.empty?
107
118
 
108
- @rpc.unary(:identify, request)
119
+ bytesize = RPC::SdkProfilesV1::IdentifyRequest.encode(request).bytesize
120
+ if bytesize > MAX_IDENTIFY_BYTES
121
+ return @log.error("Identify #{external_id.inspect} dropped: #{bytesize} bytes is too large to send " \
122
+ "(limit #{MAX_IDENTIFY_BYTES})")
123
+ end
124
+
125
+ @identify_queue.enqueue(request, bytesize)
109
126
  nil
110
127
  rescue NotSerializable => e
111
128
  @log.error("identify() traits must be JSON-serializable: #{e.message}")
112
129
  rescue StandardError => e
113
- @log.error("Failed to identify #{external_id.inspect}: #{Log.describe(e)}")
130
+ @log.error("Unexpected error in identify(#{external_id.inspect}): #{Log.describe(e)}")
114
131
  end
115
132
 
116
133
  def flush(timeout: nil)
117
- @batcher.flush(timeout: wait_timeout(timeout, "flush"))
134
+ deadline = wait_deadline(timeout, "flush")
135
+ waiters = queues.map(&:request_flush)
136
+ waiters.map { |waiter| !waiter.pop(timeout: remaining(deadline)).nil? }.all?
118
137
  end
119
138
 
139
+ # Concurrent closes can't each own one queue: the second waits on the first's timeout.
120
140
  def close(timeout: nil)
121
141
  @closed = true
122
- @batcher.close(timeout: wait_timeout(timeout, "close"))
142
+ deadline = wait_deadline(timeout, "close")
143
+ firsts = @close_lock.synchronize { queues.map(&:start_close) }
144
+ queues.zip(firsts).map { |queue, first| queue.finish_close(first, deadline) }.all?
123
145
  ensure
124
146
  @connect.close
125
147
  end
126
148
 
127
- # The default would print the API key and every buffered event.
149
+ # The default would print the API key and everything queued.
128
150
  def inspect = "#<#{self.class.name}>"
129
151
 
130
152
  private
131
153
 
132
- def send_batch(events)
154
+ def send_events(events)
133
155
  response = @rpc.unary(:batch_create, RPC::EventsV1::BatchCreateRequest.new(events: events))
134
156
  # A refused event isn't an RPC error, so this is the only sign it was lost.
135
157
  return unless response.dropped.positive?
@@ -137,6 +159,39 @@ module Pug
137
159
  @log.warn("Server dropped #{response.dropped} of #{events.size} event(s): #{response.dropped_by_reason.to_h}")
138
160
  end
139
161
 
162
+ # Falls back to one Identify per item on servers older than BatchIdentify.
163
+ def send_identifies(identifies)
164
+ return if !@unbatched && batch_identify(identifies)
165
+
166
+ identifies.each_with_index do |identify, sent|
167
+ @rpc.unary(:identify, identify)
168
+ rescue StandardError => e
169
+ raise Batcher::PartialSendError.new(sent), cause: e
170
+ end
171
+ end
172
+
173
+ def batch_identify(identifies)
174
+ @rpc.unary(:batch_identify, RPC::SdkProfilesV1::BatchIdentifyRequest.new(identifies: identifies))
175
+ true
176
+ rescue Error => e
177
+ # A server without the route answers 404, which the batcher would treat as permanent.
178
+ raise unless e.code == :unimplemented
179
+
180
+ @log.warn("Server has no BatchIdentify; sending identifies one at a time.")
181
+ @unbatched = true
182
+ false
183
+ end
184
+
185
+ # A bad session id costs the event its own session, not the event.
186
+ def session(session_id)
187
+ return @session_id if session_id.nil? || session_id == ""
188
+ return session_id if session_id.is_a?(String) && session_id.ascii_only? && Validator::UUID.match?(session_id)
189
+
190
+ @log.warn("Ignoring invalid track session_id #{session_id.inspect}; expected a UUID String. " \
191
+ "Using the client's session.")
192
+ @session_id
193
+ end
194
+
140
195
  def identify_request(external_id, traits, options)
141
196
  ids = IDENTIFY_OPTIONS.filter_map do |name|
142
197
  value = options[name]
@@ -157,6 +212,15 @@ module Pug
157
212
  DEFAULT_TIMEOUT
158
213
  end
159
214
 
215
+ def queues = [@event_queue, @identify_queue]
216
+
217
+ # From on_error, waiting could deadlock on the other queue's sender, itself in on_error.
218
+ def wait_deadline(timeout, method)
219
+ timeout = wait_timeout(timeout, method)
220
+ timeout = 0 if queues.any? { |queue| queue.sender? || queue.closing? }
221
+ timeout && (monotonic + timeout)
222
+ end
223
+
160
224
  # A timeout that isn't a number waits without one, as close must still drain; one already
161
225
  # past doesn't wait at all.
162
226
  def wait_timeout(timeout, method)
@@ -167,6 +231,14 @@ module Pug
167
231
  nil
168
232
  end
169
233
 
234
+ def remaining(deadline)
235
+ deadline && [deadline - monotonic, 0].max
236
+ end
237
+
238
+ def monotonic
239
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
240
+ end
241
+
170
242
  # Lambdas and methods check their arity, so one taking fewer arguments would fail every call.
171
243
  def takes_two?(callable)
172
244
  callable = callable.method(:call) unless callable.is_a?(Proc) || callable.is_a?(Method)
@@ -235,16 +307,33 @@ module Pug
235
307
  end
236
308
  end
237
309
 
238
- # A JSON round trip accepts what JSON does (symbol keys, nested arrays) and refuses what a
239
- # Struct can't carry, such as NaN. A Struct's numbers are doubles, so an integer past 2^53
240
- # would arrive changed.
310
+ # A Struct's numbers are doubles, so an integer past 2^53 would arrive changed.
241
311
  def to_struct(traits)
242
312
  ready = EventBuilder.json_ready(camel_case_keys(traits), max_integer: EventBuilder::MAX_SAFE_FLOAT)
243
- ::Google::Protobuf::Struct.from_hash(JSON.parse(JSON.generate(ready)))
313
+ ::Google::Protobuf::Struct.from_hash(JSON.parse(JSON.generate(finite_traits(ready))))
244
314
  rescue StandardError => e
245
315
  raise NotSerializable, e.message
246
316
  end
247
317
 
318
+ # JSON has no NaN or Infinity, so a trait holding one costs only itself.
319
+ def finite_traits(traits)
320
+ traits.reject do |key, value|
321
+ next false if finite_json?(value)
322
+
323
+ @log.warn("identify() trait #{key.inspect} is not representable (NaN or Infinity), skipping")
324
+ true
325
+ end
326
+ end
327
+
328
+ def finite_json?(value)
329
+ case value
330
+ when Float then value.finite?
331
+ when Hash then value.each_value.all? { |item| finite_json?(item) }
332
+ when Array then value.all? { |item| finite_json?(item) }
333
+ else true
334
+ end
335
+ end
336
+
248
337
  # Only the top level, as for properties: a nested Hash is as likely data keyed by ids.
249
338
  def camel_case_keys(traits)
250
339
  given = {}
@@ -8,7 +8,7 @@ require_relative '../../../buf/validate/validate_pb'
8
8
  require 'google/protobuf/struct_pb'
9
9
 
10
10
 
11
- descriptor_data = "\n\x1esdk/profiles/v1/profiles.proto\x12\x0fsdk.profiles.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xd1\x02\n\x0fIdentifyRequest\x12\xae\x01\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x8c\x01\xbaH\x88\x01\xba\x01\x81\x01\n\x1b\x65xternal_id.reserved_prefix\x12\x41\x65xternal_id must not start with the reserved \'cookieless-\' prefix\x1a\x1f!this.startsWith(\'cookieless-\')\xc8\x01\x01R\nexternalId\x12/\n\x06traits\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x06traits\x12\x36\n\x0c\x61nonymous_id\x18\x03 \x01(\tB\x13\xbaH\x10r\x0e\x18\xff\x01\x32\t^$|^anon-R\x0b\x61nonymousId\x12$\n\tdevice_id\x18\x04 \x01(\tB\x07\xbaH\x04r\x02\x18$R\x08\x64\x65viceId\"\x12\n\x10IdentifyResponse\"\xe9\x02\n\x16ProfileIdentifyMessage\x12\xb2\x01\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x90\x01\xbaH\x8c\x01r\x02\x10\x01\xba\x01\x81\x01\n\x1b\x65xternal_id.reserved_prefix\x12\x41\x65xternal_id must not start with the reserved \'cookieless-\' prefix\x1a\x1f!this.startsWith(\'cookieless-\')\xc8\x01\x01R\nexternalId\x12/\n\x06traits\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x06traits\x12)\n\nproject_id\x18\x03 \x01(\tB\n\xbaH\x07r\x02\x10\x01\xc8\x01\x01R\tprojectId\x12!\n\x0c\x61nonymous_id\x18\x04 \x01(\tR\x0b\x61nonymousId\x12\x1b\n\tdevice_id\x18\x05 \x01(\tR\x08\x64\x65viceId2e\n\x12ProfilesSDKService\x12O\n\x08Identify\x12 .sdk.profiles.v1.IdentifyRequest\x1a!.sdk.profiles.v1.IdentifyResponseB\xd6\x01\n\x13\x63om.sdk.profiles.v1B\rProfilesProtoP\x01ZFgithub.com/pug-sh/pug/internal/gen/proto/sdk/profiles/v1;sdkprofilesv1\xa2\x02\x03SPX\xaa\x02\x0fSdk.Profiles.V1\xca\x02\x0fSdk\\Profiles\\V1\xe2\x02\x1bSdk\\Profiles\\V1\\GPBMetadata\xea\x02\x1dPug::Proto::Sdk::Profiles::V1b\x08\x65\x64itionsp\xe8\x07"
11
+ descriptor_data = "\n\x1esdk/profiles/v1/profiles.proto\x12\x0fsdk.profiles.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xd5\x02\n\x0fIdentifyRequest\x12\xb2\x01\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x90\x01\xbaH\x8c\x01r\x02\x10\x01\xba\x01\x81\x01\n\x1b\x65xternal_id.reserved_prefix\x12\x41\x65xternal_id must not start with the reserved \'cookieless-\' prefix\x1a\x1f!this.startsWith(\'cookieless-\')\xc8\x01\x01R\nexternalId\x12/\n\x06traits\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x06traits\x12\x36\n\x0c\x61nonymous_id\x18\x03 \x01(\tB\x13\xbaH\x10r\x0e\x18\xff\x01\x32\t^$|^anon-R\x0b\x61nonymousId\x12$\n\tdevice_id\x18\x04 \x01(\tB\x07\xbaH\x04r\x02\x18$R\x08\x64\x65viceId\"\x12\n\x10IdentifyResponse\"c\n\x14\x42\x61tchIdentifyRequest\x12K\n\nidentifies\x18\x01 \x03(\x0b\x32 .sdk.profiles.v1.IdentifyRequestB\t\xbaH\x06\x92\x01\x03\x10\xe8\x07R\nidentifies\"3\n\x15\x42\x61tchIdentifyResponse\x12\x1a\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\rR\x08\x61\x63\x63\x65pted\"\xe9\x02\n\x16ProfileIdentifyMessage\x12\xb2\x01\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x90\x01\xbaH\x8c\x01r\x02\x10\x01\xba\x01\x81\x01\n\x1b\x65xternal_id.reserved_prefix\x12\x41\x65xternal_id must not start with the reserved \'cookieless-\' prefix\x1a\x1f!this.startsWith(\'cookieless-\')\xc8\x01\x01R\nexternalId\x12/\n\x06traits\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x06traits\x12)\n\nproject_id\x18\x03 \x01(\tB\n\xbaH\x07r\x02\x10\x01\xc8\x01\x01R\tprojectId\x12!\n\x0c\x61nonymous_id\x18\x04 \x01(\tR\x0b\x61nonymousId\x12\x1b\n\tdevice_id\x18\x05 \x01(\tR\x08\x64\x65viceId2\xc5\x01\n\x12ProfilesSDKService\x12O\n\x08Identify\x12 .sdk.profiles.v1.IdentifyRequest\x1a!.sdk.profiles.v1.IdentifyResponse\x12^\n\rBatchIdentify\x12%.sdk.profiles.v1.BatchIdentifyRequest\x1a&.sdk.profiles.v1.BatchIdentifyResponseB\xd6\x01\n\x13\x63om.sdk.profiles.v1B\rProfilesProtoP\x01ZFgithub.com/pug-sh/pug/internal/gen/proto/sdk/profiles/v1;sdkprofilesv1\xa2\x02\x03SPX\xaa\x02\x0fSdk.Profiles.V1\xca\x02\x0fSdk\\Profiles\\V1\xe2\x02\x1bSdk\\Profiles\\V1\\GPBMetadata\xea\x02\x1dPug::Proto::Sdk::Profiles::V1b\x08\x65\x64itionsp\xe8\x07"
12
12
 
13
13
  pool = ::Google::Protobuf::DescriptorPool.generated_pool
14
14
  pool.add_serialized_file(descriptor_data)
@@ -20,6 +20,8 @@ module Pug
20
20
  module V1
21
21
  IdentifyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("sdk.profiles.v1.IdentifyRequest").msgclass
22
22
  IdentifyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("sdk.profiles.v1.IdentifyResponse").msgclass
23
+ BatchIdentifyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("sdk.profiles.v1.BatchIdentifyRequest").msgclass
24
+ BatchIdentifyResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("sdk.profiles.v1.BatchIdentifyResponse").msgclass
23
25
  ProfileIdentifyMessage = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("sdk.profiles.v1.ProfileIdentifyMessage").msgclass
24
26
  end
25
27
  end
@@ -10,7 +10,7 @@ require 'google/protobuf/struct_pb'
10
10
  require 'google/protobuf/timestamp_pb'
11
11
 
12
12
 
13
- descriptor_data = "\n!shared/profiles/v1/profiles.proto\x12\x12shared.profiles.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x17\x63ommon/v1/filters.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\'\n\rDeleteRequest\x12\x16\n\x02id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x02id\"C\n\x18\x44\x65leteDataSubjectRequest\x12\'\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\nexternalId\"t\n\x0e\x44\x65leteResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x43\n\x06status\x18\x02 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\"\x7f\n\x19\x44\x65leteDataSubjectResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x43\n\x06status\x18\x02 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\"B\n\x19GetDeletionRequestRequest\x12%\n\nrequest_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\trequestId\"\x81\x03\n\x1aGetDeletionRequestResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n\x0b\x65xternal_id\x18\x02 \x01(\tR\nexternalId\x12\x1d\n\nprofile_id\x18\x03 \x01(\tR\tprofileId\x12\x43\n\x06status\x18\x04 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\x12+\n\x11\x65vents_identified\x18\x05 \x01(\x03R\x10\x65ventsIdentified\x12=\n\x0crequested_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0brequestedAt\x12=\n\x0c\x63ompleted_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0b\x63ompletedAt\x12\x14\n\x05\x65rror\x18\x08 \x01(\tR\x05\x65rror\"d\n\x16GetByExternalIdRequest\x12\'\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\nexternalId\x12!\n\x0cinclude_bots\x18\x02 \x01(\x08R\x0bincludeBots\"P\n\x17GetByExternalIdResponse\x12\x35\n\x07profile\x18\x01 \x01(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x07profile\"G\n\nGetRequest\x12\x16\n\x02id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x02id\x12!\n\x0cinclude_bots\x18\x02 \x01(\x08R\x0bincludeBots\"D\n\x0bGetResponse\x12\x35\n\x07profile\x18\x01 \x01(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x07profile\"\x84\x01\n\x0b\x46ilterGroup\x12=\n\x07\x66ilters\x18\x01 \x03(\x0b\x32\x19.common.v1.PropertyFilterB\x08\xbaH\x05\x92\x01\x02\x08\x01R\x07\x66ilters\x12\x36\n\x08operator\x18\x02 \x01(\x0e\x32\x1a.common.v1.LogicalOperatorR\x08operator\"\xe7\x01\n\x0bListRequest\x12\x1d\n\npage_token\x18\x01 \x01(\tR\tpageToken\x12\x44\n\rfilter_groups\x18\x02 \x03(\x0b\x32\x1f.shared.profiles.v1.FilterGroupR\x0c\x66ilterGroups\x12P\n\x16\x66ilter_groups_operator\x18\x03 \x01(\x0e\x32\x1a.common.v1.LogicalOperatorR\x14\x66ilterGroupsOperator\x12!\n\x0cinclude_bots\x18\x04 \x01(\x08R\x0bincludeBots\"o\n\x0cListResponse\x12\x37\n\x08profiles\x18\x01 \x03(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x08profiles\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xcb\x03\n\x16ProfileActivitySummary\x12\x39\n\nfirst_seen\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tfirstSeen\x12\x37\n\tlast_seen\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x08lastSeen\x12!\n\x0ctotal_events\x18\x03 \x01(\x03R\x0btotalEvents\x12\x1c\n\tpageviews\x18\x04 \x01(\x03R\tpageviews\x12\x1a\n\x08sessions\x18\x05 \x01(\x03R\x08sessions\x12\x18\n\x07\x62rowser\x18\x06 \x01(\tR\x07\x62rowser\x12\'\n\x0f\x62rowser_version\x18\x07 \x01(\tR\x0e\x62rowserVersion\x12\x0e\n\x02os\x18\x08 \x01(\tR\x02os\x12\x1d\n\nos_version\x18\t \x01(\tR\tosVersion\x12\x16\n\x06\x64\x65vice\x18\n \x01(\tR\x06\x64\x65vice\x12\x18\n\x07\x63ountry\x18\x0b \x01(\tR\x07\x63ountry\x12\x16\n\x06region\x18\x0c \x01(\tR\x06region\x12\x12\n\x04\x63ity\x18\r \x01(\tR\x04\x63ity\x12\x10\n\x03\x62ot\x18\x0e \x01(\x08R\x03\x62ot\"\xd4\x02\n\x07Profile\x12;\n\x0b\x63reate_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ncreateTime\x12\x1f\n\x0b\x65xternal_id\x18\x02 \x01(\tR\nexternalId\x12\x0e\n\x02id\x18\x03 \x01(\tR\x02id\x12\x37\n\nproperties\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\nproperties\x12\x1d\n\nproject_id\x18\x05 \x01(\tR\tprojectId\x12;\n\x0bupdate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\nupdateTime\x12\x46\n\x08\x61\x63tivity\x18\x07 \x01(\x0b\x32*.shared.profiles.v1.ProfileActivitySummaryR\x08\x61\x63tivity*\xe4\x01\n\x17\x43omplianceRequestStatus\x12)\n%COMPLIANCE_REQUEST_STATUS_UNSPECIFIED\x10\x00\x12%\n!COMPLIANCE_REQUEST_STATUS_PENDING\x10\x01\x12(\n$COMPLIANCE_REQUEST_STATUS_PROCESSING\x10\x02\x12\'\n#COMPLIANCE_REQUEST_STATUS_COMPLETED\x10\x03\x12$\n COMPLIANCE_REQUEST_STATUS_FAILED\x10\x04\x32\xd6\x04\n\x0fProfilesService\x12H\n\x03Get\x12\x1e.shared.profiles.v1.GetRequest\x1a\x1f.shared.profiles.v1.GetResponse\"\x00\x12l\n\x0fGetByExternalId\x12*.shared.profiles.v1.GetByExternalIdRequest\x1a+.shared.profiles.v1.GetByExternalIdResponse\"\x00\x12M\n\x04List\x12\x1f.shared.profiles.v1.ListRequest\x1a .shared.profiles.v1.ListResponse\"\x00\x30\x01\x12Q\n\x06\x44\x65lete\x12!.shared.profiles.v1.DeleteRequest\x1a\".shared.profiles.v1.DeleteResponse\"\x00\x12r\n\x11\x44\x65leteDataSubject\x12,.shared.profiles.v1.DeleteDataSubjectRequest\x1a-.shared.profiles.v1.DeleteDataSubjectResponse\"\x00\x12u\n\x12GetDeletionRequest\x12-.shared.profiles.v1.GetDeletionRequestRequest\x1a..shared.profiles.v1.GetDeletionRequestResponse\"\x00\x42\xe5\x01\n\x16\x63om.shared.profiles.v1B\rProfilesProtoP\x01ZFgithub.com/pug-sh/pug/internal/gen/proto/shared/profiles/v1;profilesv1\xa2\x02\x03SPX\xaa\x02\x12Shared.Profiles.V1\xca\x02\x12Shared\\Profiles\\V1\xe2\x02\x1eShared\\Profiles\\V1\\GPBMetadata\xea\x02 Pug::Proto::Shared::Profiles::V1b\x08\x65\x64itionsp\xe8\x07"
13
+ descriptor_data = "\n!shared/profiles/v1/profiles.proto\x12\x12shared.profiles.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x17\x63ommon/v1/filters.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\'\n\rDeleteRequest\x12\x16\n\x02id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x02id\"C\n\x18\x44\x65leteDataSubjectRequest\x12\'\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\nexternalId\"t\n\x0e\x44\x65leteResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x43\n\x06status\x18\x02 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\"\x7f\n\x19\x44\x65leteDataSubjectResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x43\n\x06status\x18\x02 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\"B\n\x19GetDeletionRequestRequest\x12%\n\nrequest_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\trequestId\"\x81\x03\n\x1aGetDeletionRequestResponse\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n\x0b\x65xternal_id\x18\x02 \x01(\tR\nexternalId\x12\x1d\n\nprofile_id\x18\x03 \x01(\tR\tprofileId\x12\x43\n\x06status\x18\x04 \x01(\x0e\x32+.shared.profiles.v1.ComplianceRequestStatusR\x06status\x12+\n\x11\x65vents_identified\x18\x05 \x01(\x03R\x10\x65ventsIdentified\x12=\n\x0crequested_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0brequestedAt\x12=\n\x0c\x63ompleted_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0b\x63ompletedAt\x12\x14\n\x05\x65rror\x18\x08 \x01(\tR\x05\x65rror\"d\n\x16GetByExternalIdRequest\x12\'\n\x0b\x65xternal_id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\nexternalId\x12!\n\x0cinclude_bots\x18\x02 \x01(\x08R\x0bincludeBots\"P\n\x17GetByExternalIdResponse\x12\x35\n\x07profile\x18\x01 \x01(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x07profile\"G\n\nGetRequest\x12\x16\n\x02id\x18\x01 \x01(\tB\x06\xbaH\x03\xc8\x01\x01R\x02id\x12!\n\x0cinclude_bots\x18\x02 \x01(\x08R\x0bincludeBots\"D\n\x0bGetResponse\x12\x35\n\x07profile\x18\x01 \x01(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x07profile\"\x84\x01\n\x0b\x46ilterGroup\x12=\n\x07\x66ilters\x18\x01 \x03(\x0b\x32\x19.common.v1.PropertyFilterB\x08\xbaH\x05\x92\x01\x02\x08\x01R\x07\x66ilters\x12\x36\n\x08operator\x18\x02 \x01(\x0e\x32\x1a.common.v1.LogicalOperatorR\x08operator\"\xaa\x02\n\x0bListRequest\x12\x1d\n\npage_token\x18\x01 \x01(\tR\tpageToken\x12\x44\n\rfilter_groups\x18\x02 \x03(\x0b\x32\x1f.shared.profiles.v1.FilterGroupR\x0c\x66ilterGroups\x12P\n\x16\x66ilter_groups_operator\x18\x03 \x01(\x0e\x32\x1a.common.v1.LogicalOperatorR\x14\x66ilterGroupsOperator\x12!\n\x0cinclude_bots\x18\x04 \x01(\x08R\x0bincludeBots\x12\x41\n\x04sort\x18\x05 \x01(\x0e\x32#.shared.profiles.v1.ProfileListSortB\x08\xbaH\x05\x82\x01\x02\x10\x01R\x04sort\"o\n\x0cListResponse\x12\x37\n\x08profiles\x18\x01 \x03(\x0b\x32\x1b.shared.profiles.v1.ProfileR\x08profiles\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xcb\x03\n\x16ProfileActivitySummary\x12\x39\n\nfirst_seen\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tfirstSeen\x12\x37\n\tlast_seen\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x08lastSeen\x12!\n\x0ctotal_events\x18\x03 \x01(\x03R\x0btotalEvents\x12\x1c\n\tpageviews\x18\x04 \x01(\x03R\tpageviews\x12\x1a\n\x08sessions\x18\x05 \x01(\x03R\x08sessions\x12\x18\n\x07\x62rowser\x18\x06 \x01(\tR\x07\x62rowser\x12\'\n\x0f\x62rowser_version\x18\x07 \x01(\tR\x0e\x62rowserVersion\x12\x0e\n\x02os\x18\x08 \x01(\tR\x02os\x12\x1d\n\nos_version\x18\t \x01(\tR\tosVersion\x12\x16\n\x06\x64\x65vice\x18\n \x01(\tR\x06\x64\x65vice\x12\x18\n\x07\x63ountry\x18\x0b \x01(\tR\x07\x63ountry\x12\x16\n\x06region\x18\x0c \x01(\tR\x06region\x12\x12\n\x04\x63ity\x18\r \x01(\tR\x04\x63ity\x12\x10\n\x03\x62ot\x18\x0e \x01(\x08R\x03\x62ot\"\xd4\x02\n\x07Profile\x12;\n\x0b\x63reate_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\ncreateTime\x12\x1f\n\x0b\x65xternal_id\x18\x02 \x01(\tR\nexternalId\x12\x0e\n\x02id\x18\x03 \x01(\tR\x02id\x12\x37\n\nproperties\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\nproperties\x12\x1d\n\nproject_id\x18\x05 \x01(\tR\tprojectId\x12;\n\x0bupdate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\nupdateTime\x12\x46\n\x08\x61\x63tivity\x18\x07 \x01(\x0b\x32*.shared.profiles.v1.ProfileActivitySummaryR\x08\x61\x63tivity*\xe4\x01\n\x17\x43omplianceRequestStatus\x12)\n%COMPLIANCE_REQUEST_STATUS_UNSPECIFIED\x10\x00\x12%\n!COMPLIANCE_REQUEST_STATUS_PENDING\x10\x01\x12(\n$COMPLIANCE_REQUEST_STATUS_PROCESSING\x10\x02\x12\'\n#COMPLIANCE_REQUEST_STATUS_COMPLETED\x10\x03\x12$\n COMPLIANCE_REQUEST_STATUS_FAILED\x10\x04*\x9a\x01\n\x0fProfileListSort\x12!\n\x1dPROFILE_LIST_SORT_UNSPECIFIED\x10\x00\x12!\n\x1dPROFILE_LIST_SORT_CREATE_TIME\x10\x01\x12 \n\x1cPROFILE_LIST_SORT_FIRST_SEEN\x10\x02\x12\x1f\n\x1bPROFILE_LIST_SORT_LAST_SEEN\x10\x03\x32\xd6\x04\n\x0fProfilesService\x12H\n\x03Get\x12\x1e.shared.profiles.v1.GetRequest\x1a\x1f.shared.profiles.v1.GetResponse\"\x00\x12l\n\x0fGetByExternalId\x12*.shared.profiles.v1.GetByExternalIdRequest\x1a+.shared.profiles.v1.GetByExternalIdResponse\"\x00\x12M\n\x04List\x12\x1f.shared.profiles.v1.ListRequest\x1a .shared.profiles.v1.ListResponse\"\x00\x30\x01\x12Q\n\x06\x44\x65lete\x12!.shared.profiles.v1.DeleteRequest\x1a\".shared.profiles.v1.DeleteResponse\"\x00\x12r\n\x11\x44\x65leteDataSubject\x12,.shared.profiles.v1.DeleteDataSubjectRequest\x1a-.shared.profiles.v1.DeleteDataSubjectResponse\"\x00\x12u\n\x12GetDeletionRequest\x12-.shared.profiles.v1.GetDeletionRequestRequest\x1a..shared.profiles.v1.GetDeletionRequestResponse\"\x00\x42\xe5\x01\n\x16\x63om.shared.profiles.v1B\rProfilesProtoP\x01ZFgithub.com/pug-sh/pug/internal/gen/proto/shared/profiles/v1;profilesv1\xa2\x02\x03SPX\xaa\x02\x12Shared.Profiles.V1\xca\x02\x12Shared\\Profiles\\V1\xe2\x02\x1eShared\\Profiles\\V1\\GPBMetadata\xea\x02 Pug::Proto::Shared::Profiles::V1b\x08\x65\x64itionsp\xe8\x07"
14
14
 
15
15
  pool = ::Google::Protobuf::DescriptorPool.generated_pool
16
16
  pool.add_serialized_file(descriptor_data)
@@ -36,6 +36,7 @@ module Pug
36
36
  ProfileActivitySummary = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("shared.profiles.v1.ProfileActivitySummary").msgclass
37
37
  Profile = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("shared.profiles.v1.Profile").msgclass
38
38
  ComplianceRequestStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("shared.profiles.v1.ComplianceRequestStatus").enummodule
39
+ ProfileListSort = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("shared.profiles.v1.ProfileListSort").enummodule
39
40
  end
40
41
  end
41
42
  end
data/lib/pug/rpc.rb CHANGED
@@ -17,6 +17,8 @@ module Pug
17
17
  EventsV1::BatchCreateResponse],
18
18
  identify: ["sdk.profiles.v1.ProfilesSDKService/Identify", SdkProfilesV1::IdentifyRequest,
19
19
  SdkProfilesV1::IdentifyResponse],
20
+ batch_identify: ["sdk.profiles.v1.ProfilesSDKService/BatchIdentify", SdkProfilesV1::BatchIdentifyRequest,
21
+ SdkProfilesV1::BatchIdentifyResponse],
20
22
  profiles_get: ["shared.profiles.v1.ProfilesService/Get", ProfilesV1::GetRequest, ProfilesV1::GetResponse],
21
23
  profiles_get_by_external_id: ["shared.profiles.v1.ProfilesService/GetByExternalId",
22
24
  ProfilesV1::GetByExternalIdRequest, ProfilesV1::GetByExternalIdResponse],
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.2"
4
+ VERSION = "0.1.0"
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.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pug.sh
@@ -117,6 +117,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
117
117
  requirements: []
118
118
  rubygems_version: 4.0.21
119
119
  specification_version: 4
120
- summary: Pug server-side SDK for Ruby — event tracking, profile identify, and analytics
121
- reads.
120
+ summary: 'Pug server-side SDK for Ruby: event tracking, profile identify, and analytics
121
+ reads.'
122
122
  test_files: []