pug-sh 0.0.2 → 0.1.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 +4 -4
- data/README.md +50 -40
- data/lib/pug/batcher.rb +131 -94
- data/lib/pug/client.rb +107 -18
- data/lib/pug/gen/sdk/profiles/v1/profiles_pb.rb +3 -1
- data/lib/pug/gen/shared/profiles/v1/profiles_pb.rb +2 -1
- data/lib/pug/rpc.rb +2 -0
- data/lib/pug/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 133575bdbf871efe89edf1d344fb0affedb6f8327617a539416673ace6e1a4e3
|
|
4
|
+
data.tar.gz: f828411944e0db6f36fefbd946e097fc4a79a28d57a7eff6f42adfea553094f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 01a8a2fe9e371c0c3aa2e9497d4b2088dffc01efcd6f13e143c56cbad068479af2d13ee440e81c6922695ca56bd10695f6b166e74394f7e688cbf9328f23f680
|
|
7
|
+
data.tar.gz: 4fb5b2e454c0a432f2d280285ed781f755a1bed55603e9afab0529a874bc900d43a31d4e73c45918a9009005a31138e469f45fe9ba4d75247fead4ec2262a515
|
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
|
|
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
|
|
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.
|
|
54
|
-
(`max_size`) or interval (`max_wait`).
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`on_error`
|
|
60
|
-
|
|
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
|
|
67
|
-
connections; events
|
|
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
|
|
78
|
-
| `on_error` | callable | none | Dead-letter hook for
|
|
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,
|
|
87
|
-
# dead-letter sink
|
|
88
|
-
#
|
|
89
|
-
# or the exception a
|
|
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
|
-
`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
- `pug.flush(timeout: nil)`: send the
|
|
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
|
-
|
|
109
|
-
- `pug.close(timeout: nil)`: drain and shut down; call on graceful exit so nothing
|
|
110
|
-
Returns `false` if `timeout` passed first. Either way, by the time it returns every event
|
|
111
|
-
couldn't send has gone to `on_error` and the log, including a batch still being sent
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
15
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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"), [
|
|
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
|
-
#
|
|
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
|
|
122
|
+
return waiter.close if sender?
|
|
78
123
|
|
|
79
124
|
@requests << waiter
|
|
80
125
|
start
|
|
81
126
|
@wake.signal
|
|
82
127
|
end
|
|
83
|
-
|
|
128
|
+
waiter
|
|
84
129
|
end
|
|
85
130
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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? && (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
243
|
-
#
|
|
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
|
|
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(&:
|
|
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(
|
|
334
|
+
else [:retry, requeue(undelivered)]
|
|
300
335
|
end
|
|
301
336
|
end
|
|
302
|
-
|
|
337
|
+
messages = undelivered.map(&:message)
|
|
303
338
|
case outcome
|
|
304
339
|
when :permanent
|
|
305
|
-
@log.error("Permanent error, #{
|
|
306
|
-
dead_letter(error,
|
|
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, #{
|
|
309
|
-
|
|
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
|
|
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(
|
|
320
|
-
@buffer.unshift(*
|
|
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.
|
|
363
|
+
# Called under the lock.
|
|
328
364
|
def trim
|
|
329
365
|
over = @buffer.size - @max_queue_size
|
|
330
|
-
@buffer.shift(over).map(&:
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
|
366
|
-
#
|
|
367
|
-
|
|
368
|
-
|
|
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; #{
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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\"\
|
|
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\"\
|
|
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
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- pug.sh
|
|
@@ -115,8 +115,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
116
|
version: '0'
|
|
117
117
|
requirements: []
|
|
118
|
-
rubygems_version:
|
|
118
|
+
rubygems_version: 3.6.9
|
|
119
119
|
specification_version: 4
|
|
120
|
-
summary: Pug server-side SDK for Ruby
|
|
121
|
-
reads.
|
|
120
|
+
summary: 'Pug server-side SDK for Ruby: event tracking, profile identify, and analytics
|
|
121
|
+
reads.'
|
|
122
122
|
test_files: []
|