posthog-ruby 2.9.0 → 2.11.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 +4 -4
- data/bin/posthog +7 -7
- data/lib/posthog/client.rb +150 -44
- data/lib/posthog/defaults.rb +4 -5
- data/lib/posthog/feature_flag.rb +27 -26
- data/lib/posthog/feature_flags.rb +247 -177
- data/lib/posthog/field_parser.rb +30 -19
- data/lib/posthog/logging.rb +1 -1
- data/lib/posthog/noop_worker.rb +2 -1
- data/lib/posthog/send_worker.rb +2 -1
- data/lib/posthog/transport.rb +8 -10
- data/lib/posthog/utils.rb +21 -28
- data/lib/posthog/version.rb +1 -1
- data/lib/posthog-ruby.rb +2 -0
- metadata +5 -105
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 952a3d08e42e35c4006614c4ea33cb0fb7216c250f2eade20bb6c37d12fb5b13
|
4
|
+
data.tar.gz: 9f4773ec646c3e7e4f5183d818245d0dab1f275ec5f01de1ddcc073483499fbb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f3b66458d3987de0296702f9ef32a3ec90485fb263a986ff7e703ee6ca5113be320acb28fa07b48231401898e630c24f2c11067e6a01aa5bc7860ce494b15a40
|
7
|
+
data.tar.gz: 92d1b482dbe35e57c30cbc0160a6be1f1589cb63a8871eaaefdde8c64aa49d8d1d280a05d6ecd4f55fb1a850e62f73c756878081d53b6bede4b21c9859cfd006
|
data/bin/posthog
CHANGED
@@ -11,7 +11,7 @@ program :version, '1.0.0'
|
|
11
11
|
program :description, 'PostHog API'
|
12
12
|
|
13
13
|
def json_hash(str)
|
14
|
-
|
14
|
+
JSON.parse(str) if str
|
15
15
|
end
|
16
16
|
|
17
17
|
command :capture do |c|
|
@@ -27,13 +27,13 @@ command :capture do |c|
|
|
27
27
|
c.option '--event=<event>', String, 'The event name to send with the event'
|
28
28
|
c.option '--properties=<properties>', 'The properties to send (JSON-encoded)'
|
29
29
|
|
30
|
-
c.action do |
|
30
|
+
c.action do |_args, options|
|
31
31
|
posthog =
|
32
32
|
PostHog::Client.new(
|
33
33
|
{
|
34
34
|
api_key: options.api_key,
|
35
35
|
api_host: options.api_host,
|
36
|
-
on_error:
|
36
|
+
on_error: proc { |_status, msg| print msg }
|
37
37
|
}
|
38
38
|
)
|
39
39
|
|
@@ -61,13 +61,13 @@ command :identify do |c|
|
|
61
61
|
'The distinct id to send the event as'
|
62
62
|
c.option '--properties=<properties>', 'The properties to send (JSON-encoded)'
|
63
63
|
|
64
|
-
c.action do |
|
64
|
+
c.action do |_args, options|
|
65
65
|
posthog =
|
66
66
|
PostHog::Client.new(
|
67
67
|
{
|
68
68
|
api_key: options.api_key,
|
69
69
|
api_host: options.api_host,
|
70
|
-
on_error:
|
70
|
+
on_error: proc { |_status, msg| print msg }
|
71
71
|
}
|
72
72
|
)
|
73
73
|
|
@@ -92,13 +92,13 @@ command :alias do |c|
|
|
92
92
|
c.option '--distinct-id=<distinct_id>', String, 'The distinct id'
|
93
93
|
c.option '--alias=<alias>', 'The alias to give to the distinct id'
|
94
94
|
|
95
|
-
c.action do |
|
95
|
+
c.action do |_args, options|
|
96
96
|
posthog =
|
97
97
|
PostHog::Client.new(
|
98
98
|
{
|
99
99
|
api_key: options.api_key,
|
100
100
|
api_host: options.api_host,
|
101
|
-
on_error:
|
101
|
+
on_error: proc { |_status, msg| print msg }
|
102
102
|
}
|
103
103
|
)
|
104
104
|
|
data/lib/posthog/client.rb
CHANGED
@@ -22,8 +22,12 @@ class PostHog
|
|
22
22
|
# queued for testing. Defaults to +false+.
|
23
23
|
# @option opts [Proc] :on_error Handles error calls from the API.
|
24
24
|
# @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://app.posthog.com`
|
25
|
-
# @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes.
|
26
|
-
#
|
25
|
+
# @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes.
|
26
|
+
# Measured in seconds, defaults to 30.
|
27
|
+
# @option opts [Integer] :feature_flag_request_timeout_seconds How long to wait for feature flag evaluation.
|
28
|
+
# Measured in seconds, defaults to 3.
|
29
|
+
# @option opts [Proc] :before_send A block that receives the event hash and should return either a modified hash
|
30
|
+
# to be sent to PostHog or nil to prevent the event from being sent. e.g. `before_send: ->(event) { event }`
|
27
31
|
def initialize(opts = {})
|
28
32
|
symbolize_keys!(opts)
|
29
33
|
|
@@ -34,10 +38,10 @@ class PostHog
|
|
34
38
|
@max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
|
35
39
|
@worker_mutex = Mutex.new
|
36
40
|
@worker = if opts[:test_mode]
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
+
NoopWorker.new(@queue)
|
42
|
+
else
|
43
|
+
SendWorker.new(@queue, @api_key, opts)
|
44
|
+
end
|
41
45
|
@worker_thread = nil
|
42
46
|
@feature_flags_poller = nil
|
43
47
|
@personal_api_key = opts[:personal_api_key]
|
@@ -53,8 +57,12 @@ class PostHog
|
|
53
57
|
opts[:feature_flag_request_timeout_seconds] || Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS,
|
54
58
|
opts[:on_error]
|
55
59
|
)
|
56
|
-
|
57
|
-
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE)
|
60
|
+
|
61
|
+
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
|
62
|
+
hash[key] = []
|
63
|
+
end
|
64
|
+
|
65
|
+
@before_send = opts[:before_send]
|
58
66
|
end
|
59
67
|
|
60
68
|
# Synchronously waits until the worker has cleared the queue.
|
@@ -88,6 +96,10 @@ class PostHog
|
|
88
96
|
# @option attrs [String] :event Event name
|
89
97
|
# @option attrs [Hash] :properties Event properties (optional)
|
90
98
|
# @option attrs [Bool] :send_feature_flags Whether to send feature flags with this event (optional)
|
99
|
+
# @option attrs [String] :uuid ID that uniquely identifies an event;
|
100
|
+
# events in PostHog are deduplicated by the
|
101
|
+
# combination of teamId, timestamp date,
|
102
|
+
# event name, distinct id, and UUID
|
91
103
|
# @macro common_attrs
|
92
104
|
def capture(attrs)
|
93
105
|
symbolize_keys! attrs
|
@@ -147,18 +159,34 @@ class PostHog
|
|
147
159
|
@queue.length
|
148
160
|
end
|
149
161
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
162
|
+
# TODO: In future version, rename to `feature_flag_enabled?`
|
163
|
+
def is_feature_enabled( # rubocop:disable Naming/PredicateName
|
164
|
+
flag_key,
|
165
|
+
distinct_id,
|
166
|
+
groups: {},
|
167
|
+
person_properties: {},
|
168
|
+
group_properties: {},
|
169
|
+
only_evaluate_locally: false,
|
170
|
+
send_feature_flag_events: true
|
171
|
+
)
|
172
|
+
response = get_feature_flag(
|
173
|
+
flag_key,
|
174
|
+
distinct_id,
|
175
|
+
groups: groups,
|
176
|
+
person_properties: person_properties,
|
177
|
+
group_properties: group_properties,
|
178
|
+
only_evaluate_locally: only_evaluate_locally,
|
179
|
+
send_feature_flag_events: send_feature_flag_events
|
180
|
+
)
|
181
|
+
return nil if response.nil?
|
182
|
+
|
155
183
|
!!response
|
156
184
|
end
|
157
185
|
|
158
186
|
# @param [String] flag_key The unique flag key of the feature flag
|
159
187
|
# @return [String] The decrypted value of the feature flag payload
|
160
188
|
def get_remote_config_payload(flag_key)
|
161
|
-
|
189
|
+
@feature_flags_poller.get_remote_config_payload(flag_key)
|
162
190
|
end
|
163
191
|
|
164
192
|
# Returns whether the given feature flag is enabled for the given user or not
|
@@ -168,35 +196,56 @@ class PostHog
|
|
168
196
|
# @param [Hash] groups
|
169
197
|
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
170
198
|
# @param [Hash] group_properties
|
171
|
-
#
|
199
|
+
#
|
172
200
|
# @return [String, nil] The value of the feature flag
|
173
201
|
#
|
174
202
|
# The provided properties are used to calculate feature flags locally, if possible.
|
175
203
|
#
|
176
|
-
# `groups` are a mapping from group type to group key. So, if you have a group type of "organization"
|
204
|
+
# `groups` are a mapping from group type to group key. So, if you have a group type of "organization"
|
205
|
+
# and a group key of "5",
|
177
206
|
# you would pass groups={"organization": "5"}.
|
178
207
|
# `group_properties` take the format: { group_type_name: { group_properties } }
|
179
|
-
# So, for example, if you have the group type "organization" and the group key "5", with the properties name,
|
180
|
-
# you'll send these as:
|
208
|
+
# So, for example, if you have the group type "organization" and the group key "5", with the properties name,
|
209
|
+
# and employee count, you'll send these as:
|
181
210
|
# ```ruby
|
182
211
|
# group_properties: {"organization": {"name": "PostHog", "employees": 11}}
|
183
212
|
# ```
|
184
|
-
def get_feature_flag(
|
185
|
-
|
186
|
-
|
213
|
+
def get_feature_flag(
|
214
|
+
key,
|
215
|
+
distinct_id,
|
216
|
+
groups: {},
|
217
|
+
person_properties: {},
|
218
|
+
group_properties: {},
|
219
|
+
only_evaluate_locally: false,
|
220
|
+
send_feature_flag_events: true
|
221
|
+
)
|
222
|
+
person_properties, group_properties = add_local_person_and_group_properties(
|
223
|
+
distinct_id,
|
224
|
+
groups,
|
225
|
+
person_properties,
|
226
|
+
group_properties
|
227
|
+
)
|
228
|
+
feature_flag_response, flag_was_locally_evaluated, request_id = @feature_flags_poller.get_feature_flag(
|
229
|
+
key,
|
230
|
+
distinct_id,
|
231
|
+
groups,
|
232
|
+
person_properties,
|
233
|
+
group_properties,
|
234
|
+
only_evaluate_locally
|
235
|
+
)
|
187
236
|
|
188
237
|
feature_flag_reported_key = "#{key}_#{feature_flag_response}"
|
189
238
|
if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
|
190
239
|
capture(
|
191
240
|
{
|
192
|
-
|
193
|
-
|
194
|
-
|
241
|
+
distinct_id: distinct_id,
|
242
|
+
event: '$feature_flag_called',
|
243
|
+
properties: {
|
195
244
|
'$feature_flag' => key,
|
196
245
|
'$feature_flag_response' => feature_flag_response,
|
197
246
|
'locally_evaluated' => flag_was_locally_evaluated
|
198
|
-
}.merge(request_id ? {'$feature_flag_request_id' => request_id} : {}),
|
199
|
-
|
247
|
+
}.merge(request_id ? { '$feature_flag_request_id' => request_id } : {}),
|
248
|
+
groups: groups
|
200
249
|
}
|
201
250
|
)
|
202
251
|
@distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
|
@@ -210,11 +259,19 @@ class PostHog
|
|
210
259
|
# @param [Hash] groups
|
211
260
|
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
212
261
|
# @param [Hash] group_properties
|
213
|
-
#
|
262
|
+
#
|
214
263
|
# @return [Hash] String (not symbol) key value pairs of flag and their values
|
215
|
-
def get_all_flags(
|
216
|
-
|
217
|
-
|
264
|
+
def get_all_flags(
|
265
|
+
distinct_id,
|
266
|
+
groups: {},
|
267
|
+
person_properties: {},
|
268
|
+
group_properties: {},
|
269
|
+
only_evaluate_locally: false
|
270
|
+
)
|
271
|
+
person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
|
272
|
+
person_properties, group_properties)
|
273
|
+
@feature_flags_poller.get_all_flags(distinct_id, groups, person_properties, group_properties,
|
274
|
+
only_evaluate_locally)
|
218
275
|
end
|
219
276
|
|
220
277
|
# Returns payload for a given feature flag
|
@@ -227,13 +284,23 @@ class PostHog
|
|
227
284
|
# @option [Hash] group_properties
|
228
285
|
# @option [Boolean] only_evaluate_locally
|
229
286
|
#
|
230
|
-
def get_feature_flag_payload(
|
231
|
-
|
232
|
-
|
287
|
+
def get_feature_flag_payload(
|
288
|
+
key,
|
289
|
+
distinct_id,
|
290
|
+
match_value: nil,
|
291
|
+
groups: {},
|
292
|
+
person_properties: {},
|
293
|
+
group_properties: {},
|
294
|
+
only_evaluate_locally: false
|
295
|
+
)
|
296
|
+
person_properties, group_properties = add_local_person_and_group_properties(distinct_id, groups,
|
297
|
+
person_properties, group_properties)
|
298
|
+
@feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
|
299
|
+
group_properties, only_evaluate_locally)
|
233
300
|
end
|
234
301
|
|
235
302
|
# Returns all flags and payloads for a given user
|
236
|
-
#
|
303
|
+
#
|
237
304
|
# @return [Hash] A hash with the following keys:
|
238
305
|
# featureFlags: A hash of feature flags
|
239
306
|
# featureFlagPayloads: A hash of feature flag payloads
|
@@ -244,9 +311,20 @@ class PostHog
|
|
244
311
|
# @option [Hash] group_properties
|
245
312
|
# @option [Boolean] only_evaluate_locally
|
246
313
|
#
|
247
|
-
def get_all_flags_and_payloads(
|
248
|
-
|
249
|
-
|
314
|
+
def get_all_flags_and_payloads(
|
315
|
+
distinct_id,
|
316
|
+
groups: {},
|
317
|
+
person_properties: {},
|
318
|
+
group_properties: {},
|
319
|
+
only_evaluate_locally: false
|
320
|
+
)
|
321
|
+
person_properties, group_properties = add_local_person_and_group_properties(
|
322
|
+
distinct_id, groups, person_properties, group_properties
|
323
|
+
)
|
324
|
+
response = @feature_flags_poller.get_all_flags_and_payloads(
|
325
|
+
distinct_id, groups, person_properties, group_properties, only_evaluate_locally
|
326
|
+
)
|
327
|
+
|
250
328
|
response.delete(:requestId) # remove internal information.
|
251
329
|
response
|
252
330
|
end
|
@@ -268,10 +346,36 @@ class PostHog
|
|
268
346
|
|
269
347
|
private
|
270
348
|
|
349
|
+
# before_send should run immediately before the event is sent to the queue.
|
350
|
+
# @param [Object] action The event to be sent to PostHog
|
351
|
+
# @return [null, Object, nil] The processed event or nil if the event should not be sent
|
352
|
+
def process_before_send(action)
|
353
|
+
return action if action.nil? || action.empty?
|
354
|
+
return action unless @before_send
|
355
|
+
|
356
|
+
begin
|
357
|
+
processed_action = @before_send.call(action)
|
358
|
+
|
359
|
+
if processed_action.nil?
|
360
|
+
logger.warn("Event #{action[:event]} was rejected in beforeSend function")
|
361
|
+
elsif processed_action.empty?
|
362
|
+
logger.warn("Event #{action[:event]} has no properties after beforeSend function, this is likely an error")
|
363
|
+
end
|
364
|
+
|
365
|
+
processed_action
|
366
|
+
rescue StandardError => e
|
367
|
+
logger.error("Error in beforeSend function - using original event: #{e.message}")
|
368
|
+
action
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
271
372
|
# private: Enqueues the action.
|
272
373
|
#
|
273
374
|
# returns Boolean of whether the item was added to the queue.
|
274
375
|
def enqueue(action)
|
376
|
+
action = process_before_send(action)
|
377
|
+
return false if action.nil? || action.empty?
|
378
|
+
|
275
379
|
# add our request id for tracing purposes
|
276
380
|
action[:messageId] ||= uid
|
277
381
|
|
@@ -283,8 +387,8 @@ class PostHog
|
|
283
387
|
else
|
284
388
|
logger.warn(
|
285
389
|
'Queue is full, dropping events. The :max_queue_size ' \
|
286
|
-
|
287
|
-
|
390
|
+
'configuration parameter can be increased to prevent this from ' \
|
391
|
+
'happening.'
|
288
392
|
)
|
289
393
|
false
|
290
394
|
end
|
@@ -297,8 +401,10 @@ class PostHog
|
|
297
401
|
|
298
402
|
def ensure_worker_running
|
299
403
|
return if worker_running?
|
404
|
+
|
300
405
|
@worker_mutex.synchronize do
|
301
406
|
return if worker_running?
|
407
|
+
|
302
408
|
@worker_thread = Thread.new { @worker.run }
|
303
409
|
end
|
304
410
|
end
|
@@ -308,7 +414,6 @@ class PostHog
|
|
308
414
|
end
|
309
415
|
|
310
416
|
def add_local_person_and_group_properties(distinct_id, groups, person_properties, group_properties)
|
311
|
-
|
312
417
|
groups ||= {}
|
313
418
|
person_properties ||= {}
|
314
419
|
group_properties ||= {}
|
@@ -317,21 +422,22 @@ class PostHog
|
|
317
422
|
symbolize_keys! person_properties
|
318
423
|
symbolize_keys! group_properties
|
319
424
|
|
320
|
-
group_properties.
|
425
|
+
group_properties.each_value do |value|
|
321
426
|
symbolize_keys! value
|
322
427
|
end
|
323
428
|
|
324
|
-
all_person_properties = {
|
429
|
+
all_person_properties = { distinct_id: distinct_id }.merge(person_properties)
|
325
430
|
|
326
431
|
all_group_properties = {}
|
327
432
|
if groups
|
328
433
|
groups.each do |group_name, group_key|
|
329
434
|
all_group_properties[group_name] = {
|
330
|
-
|
435
|
+
:'$group_key' => group_key
|
436
|
+
}.merge((group_properties && group_properties[group_name]) || {})
|
331
437
|
end
|
332
438
|
end
|
333
439
|
|
334
|
-
|
440
|
+
[all_person_properties, all_group_properties]
|
335
441
|
end
|
336
442
|
end
|
337
443
|
end
|
data/lib/posthog/defaults.rb
CHANGED
@@ -1,25 +1,24 @@
|
|
1
1
|
class PostHog
|
2
2
|
module Defaults
|
3
|
-
|
4
3
|
MAX_HASH_SIZE = 50_000
|
5
4
|
|
6
5
|
module Request
|
7
|
-
HOST = 'app.posthog.com'
|
6
|
+
HOST = 'app.posthog.com'.freeze
|
8
7
|
PORT = 443
|
9
|
-
PATH = '/batch/'
|
8
|
+
PATH = '/batch/'.freeze
|
10
9
|
SSL = true
|
11
10
|
HEADERS = {
|
12
11
|
'Accept' => 'application/json',
|
13
12
|
'Content-Type' => 'application/json',
|
14
13
|
'User-Agent' => "posthog-ruby/#{PostHog::VERSION}"
|
15
|
-
}
|
14
|
+
}.freeze
|
16
15
|
RETRIES = 10
|
17
16
|
end
|
18
17
|
|
19
18
|
module FeatureFlags
|
20
19
|
FLAG_REQUEST_TIMEOUT_SECONDS = 3
|
21
20
|
end
|
22
|
-
|
21
|
+
|
23
22
|
module Queue
|
24
23
|
MAX_SIZE = 10_000
|
25
24
|
end
|
data/lib/posthog/feature_flag.rb
CHANGED
@@ -4,34 +4,35 @@ class FeatureFlag
|
|
4
4
|
|
5
5
|
def initialize(json)
|
6
6
|
json.transform_keys!(&:to_s)
|
7
|
-
@key = json[
|
8
|
-
@enabled = json[
|
9
|
-
@variant = json[
|
10
|
-
@reason = json[
|
11
|
-
@metadata = json[
|
7
|
+
@key = json['key']
|
8
|
+
@enabled = json['enabled']
|
9
|
+
@variant = json['variant']
|
10
|
+
@reason = json['reason'] ? EvaluationReason.new(json['reason']) : nil
|
11
|
+
@metadata = json['metadata'] ? FeatureFlagMetadata.new(json['metadata'].transform_keys(&:to_s)) : nil
|
12
12
|
end
|
13
13
|
|
14
|
-
|
14
|
+
# TODO: Rename to `value` in future version
|
15
|
+
def get_value # rubocop:disable Naming/AccessorMethodName
|
15
16
|
@variant || @enabled
|
16
17
|
end
|
17
18
|
|
18
19
|
def payload
|
19
|
-
@metadata
|
20
|
+
@metadata.payload if @metadata
|
20
21
|
end
|
21
22
|
|
22
23
|
def self.from_value_and_payload(key, value, payload)
|
23
24
|
new({
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
25
|
+
'key' => key,
|
26
|
+
'enabled' => value.is_a?(String) || value,
|
27
|
+
'variant' => value.is_a?(String) ? value : nil,
|
28
|
+
'reason' => nil,
|
29
|
+
'metadata' => {
|
30
|
+
'id' => nil,
|
31
|
+
'version' => nil,
|
32
|
+
'payload' => payload,
|
33
|
+
'description' => nil
|
34
|
+
}
|
35
|
+
})
|
35
36
|
end
|
36
37
|
end
|
37
38
|
|
@@ -41,9 +42,9 @@ class EvaluationReason
|
|
41
42
|
|
42
43
|
def initialize(json)
|
43
44
|
json.transform_keys!(&:to_s)
|
44
|
-
@code = json[
|
45
|
-
@description = json[
|
46
|
-
@condition_index = json[
|
45
|
+
@code = json['code']
|
46
|
+
@description = json['description']
|
47
|
+
@condition_index = json['condition_index'].to_i if json['condition_index']
|
47
48
|
end
|
48
49
|
end
|
49
50
|
|
@@ -53,9 +54,9 @@ class FeatureFlagMetadata
|
|
53
54
|
|
54
55
|
def initialize(json)
|
55
56
|
json.transform_keys!(&:to_s)
|
56
|
-
@id = json[
|
57
|
-
@version = json[
|
58
|
-
@payload = json[
|
59
|
-
@description = json[
|
57
|
+
@id = json['id']
|
58
|
+
@version = json['version']
|
59
|
+
@payload = json['payload']
|
60
|
+
@description = json['description']
|
60
61
|
end
|
61
|
-
end
|
62
|
+
end
|