recapi 0.1.4 → 0.1.7
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 +29 -7
- data/lib/recapi.rb +85 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8edb463693be71a5cac3a101441d5fe6c045e5e58f4003025291995ec5c9e44
|
|
4
|
+
data.tar.gz: 818ea20b257819a76f15c832af44b83694681fc36995f08b91987ac2b9e8afdf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb54199fc5b2ff480e94be20d594598a2cbda5fb2b1b8ffb854b3dd4ac0dd3d9d2858b05e67cc79b264d93f41f4e5132b57c50812aa5c626582583d369763df7
|
|
7
|
+
data.tar.gz: 33fbcbfc1cca21caf75849d106e9500236d6adf2ac897c74b49e9731eb79b6680cb1054e637677babbd0ebc52fc853aa7a999ff10a1e11911da5ff01bb538c41
|
data/README.md
CHANGED
|
@@ -30,7 +30,7 @@ line with your address): save it as `lib/recapi.rb` and
|
|
|
30
30
|
`require Rails.root.join("lib/recapi").to_s` instead.
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.
|
|
33
|
+
curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.7.rb
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Quick start
|
|
@@ -41,7 +41,7 @@ require "recapi"
|
|
|
41
41
|
|
|
42
42
|
# A secret key (rk_sec_…): it stays on your server.
|
|
43
43
|
RECAPI = Recapi::Client.new(ENV.fetch("RECAPI_KEY"), "https://api.example.com",
|
|
44
|
-
|
|
44
|
+
logger: Rails.logger)
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
```ruby
|
|
@@ -79,10 +79,10 @@ API failure `Recapi::Error` (`code`, `status`, `retry_after`, `retryable?`,
|
|
|
79
79
|
|
|
80
80
|
`Recapi::Client.new(api_key, base_url, store:, timeout:, flush_interval:,
|
|
81
81
|
max_batch:, logger:)` sets the options. The default `Recapi::MemoryStore`
|
|
82
|
-
keeps the 10,000 most recent users in the process; pass
|
|
83
|
-
|
|
84
|
-
several processes serve the same users. It
|
|
85
|
-
Python library, so both can share one store. Written to `Rails.cache` or a
|
|
82
|
+
keeps the 10,000 most recent users in the process; pass a Redis client
|
|
83
|
+
(`get`/`set`/`del`) or a `Rails.cache` on Redis, Memcached or Solid Cache (it
|
|
84
|
+
has `read`/`write`/`delete`) when several processes serve the same users. It
|
|
85
|
+
stores the same JSON as the Python library, so both can share one store. Written to `Rails.cache` or a
|
|
86
86
|
redis-rb client, a user's state lasts 30 days from its last write, with no
|
|
87
87
|
setting to choose: well past the 24 hours a click earns credit, and recapi gives
|
|
88
88
|
a known `user_id` its history back afterwards. Any other store gets a plain
|
|
@@ -93,7 +93,29 @@ client; a forked worker (Puma cluster, Unicorn, Passenger) starts its own
|
|
|
93
93
|
queue and sender at its first call. Proxies come from `HTTPS_PROXY`,
|
|
94
94
|
`HTTP_PROXY` and `NO_PROXY`, as `Net::HTTP` reads them. Failures are logged
|
|
95
95
|
to `logger:`, anything with `warn` and `error` (`Rails.logger`, a
|
|
96
|
-
`Logger`); without one they go to `$stderr` as `recapi: …` lines
|
|
96
|
+
`Logger`); without one they go to `$stderr` as `recapi: …` lines, with lost
|
|
97
|
+
events (a refused batch, rejected events) at most one line a minute for each
|
|
98
|
+
error code and for rejected events, so a revoked key on a busy server doesn't
|
|
99
|
+
flood the log, and a full queue one line until a send works. A failed write to
|
|
100
|
+
`$stderr` (a full disk, a closed stream) is ignored. A logger you give gets
|
|
101
|
+
every line, and if it raises, that reaches your code.
|
|
102
|
+
|
|
103
|
+
### Which Rails caches work
|
|
104
|
+
|
|
105
|
+
Not every `Rails.cache` can hold this, and Rails' defaults often can't. The
|
|
106
|
+
library recognises ActiveSupport's own classes (a subclass of one is left alone)
|
|
107
|
+
and logs one line at start:
|
|
108
|
+
|
|
109
|
+
| `Rails.cache` | Where Rails sets it | What happens | Shared between |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| `RedisCacheStore`, `MemCacheStore`, Solid Cache | your choice; Solid Cache in new 8.x apps' production | used, no line | servers |
|
|
112
|
+
| `FileStore` | production on 7.2 and older | used, with a warning | one server's processes |
|
|
113
|
+
| `MemoryStore` | development on 8.x | the library's own store instead, with a warning | nothing |
|
|
114
|
+
| `NullStore` | development on 7.2 and older, without `rails dev:cache` | the library's own store instead, with a warning | nothing |
|
|
115
|
+
|
|
116
|
+
`FileStore` frees an expired entry only when it is read again or `cleanup` runs,
|
|
117
|
+
so a visitor who never returns stays on disk (about 8 KB each): run
|
|
118
|
+
`Rails.cache.cleanup` on a schedule, or leave `store:` out.
|
|
97
119
|
|
|
98
120
|
## Development
|
|
99
121
|
|
data/lib/recapi.rb
CHANGED
|
@@ -25,7 +25,7 @@ rescue LoadError
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
module Recapi
|
|
28
|
-
VERSION = "0.1.
|
|
28
|
+
VERSION = "0.1.7"
|
|
29
29
|
|
|
30
30
|
# Decayed user history, exactly as docs/history-spec.md (times in unix ms).
|
|
31
31
|
# A port of sdk/python's history.py, snapshot merging included.
|
|
@@ -319,7 +319,8 @@ module Recapi
|
|
|
319
319
|
end
|
|
320
320
|
|
|
321
321
|
# In-process store keeping the most recently used max_users users. Pass a
|
|
322
|
-
# shared one (Redis, Rails.cache
|
|
322
|
+
# shared one (Redis, a Rails.cache on Redis, Memcached or Solid Cache) when
|
|
323
|
+
# several processes serve the same users.
|
|
323
324
|
class MemoryStore
|
|
324
325
|
def initialize(max_users: 10_000)
|
|
325
326
|
@max = [1, max_users].max
|
|
@@ -353,11 +354,21 @@ module Recapi
|
|
|
353
354
|
# Bundler app without it in its Gemfile can't require.
|
|
354
355
|
class StderrLogger
|
|
355
356
|
def warn(message)
|
|
356
|
-
|
|
357
|
+
write_line(message)
|
|
357
358
|
end
|
|
358
359
|
|
|
359
360
|
def error(message)
|
|
361
|
+
write_line(message)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
private
|
|
365
|
+
|
|
366
|
+
# A full disk or a closed stderr must not reach the app's track or kill the
|
|
367
|
+
# sender thread: a line nobody can read is no reason to stop sending.
|
|
368
|
+
def write_line(message)
|
|
360
369
|
$stderr.write("#{message}\n")
|
|
370
|
+
rescue StandardError
|
|
371
|
+
nil
|
|
361
372
|
end
|
|
362
373
|
end
|
|
363
374
|
|
|
@@ -377,6 +388,7 @@ module Recapi
|
|
|
377
388
|
LEVEL_NAMES = { "view" => 3, "cart" => 5, "purchase" => 10, "reject" => -10 }.freeze
|
|
378
389
|
# Events waiting for a send: a minute-long outage at 150 events/s fits.
|
|
379
390
|
MAX_QUEUE = 10_000
|
|
391
|
+
LOST_LINE_MS = 60_000
|
|
380
392
|
MAX_FLUSH_BACKOFF = 60.0
|
|
381
393
|
RETRIES = 3
|
|
382
394
|
RETRY_BASE = 0.5
|
|
@@ -396,6 +408,16 @@ module Recapi
|
|
|
396
408
|
# starts afresh. It bounds a Redis at about one month's visitors instead of
|
|
397
409
|
# growing for ever.
|
|
398
410
|
STATE_LIFETIME_SECONDS = 30 * 24 * 60 * 60
|
|
411
|
+
# ActiveSupport's caches that lose visitors' state, by exact class: a subclass
|
|
412
|
+
# may have changed what makes them so. They keep nothing or only in this
|
|
413
|
+
# process, where our MemoryStore is as local, holds more and competes with
|
|
414
|
+
# nothing else in the app's cache.
|
|
415
|
+
USE_OURS = {
|
|
416
|
+
"ActiveSupport::Cache::NullStore" => "keeps nothing",
|
|
417
|
+
"ActiveSupport::Cache::MemoryStore" => "keeps its entries in this process only"
|
|
418
|
+
}.freeze
|
|
419
|
+
FILE_STORE = "ActiveSupport::Cache::FileStore"
|
|
420
|
+
private_constant :USE_OURS, :FILE_STORE
|
|
399
421
|
# Where redis-rb defines set: 4.x and 5 on, older versions, Redis::Distributed.
|
|
400
422
|
REDIS_RB_SET = %w[Redis::Commands::Strings Redis Redis::Distributed].freeze
|
|
401
423
|
# Open connections kept between calls: one per call in flight at the busiest moment.
|
|
@@ -426,7 +448,8 @@ module Recapi
|
|
|
426
448
|
# api_key: secret key (rk_sec_...): a server can then restore a user's
|
|
427
449
|
# history from recapi when the store lacks it. base_url: your recapi API
|
|
428
450
|
# origin. store: per-user state, anything with get/set/delete (a Redis
|
|
429
|
-
# client) or read/write/delete (Rails.cache); defaults to a MemoryStore
|
|
451
|
+
# client) or read/write/delete (Rails.cache); defaults to a MemoryStore,
|
|
452
|
+
# which also replaces ActiveSupport's NullStore and MemoryStore.
|
|
430
453
|
# timeout: per-request seconds (recommend() uses at most 1.5).
|
|
431
454
|
# flush_interval: seconds between background sends. max_batch: events per
|
|
432
455
|
# request, at most the API's 1,000. logger: where failures are logged,
|
|
@@ -443,12 +466,14 @@ module Recapi
|
|
|
443
466
|
raise ArgumentError, "base_url must be an http(s) URL"
|
|
444
467
|
end
|
|
445
468
|
|
|
446
|
-
@
|
|
469
|
+
@logger = logger || StderrLogger.new
|
|
470
|
+
@limit_lost_lines = logger.nil?
|
|
471
|
+
@lost_printed = {}
|
|
472
|
+
@store = store ? store_to_use(store) : MemoryStore.new
|
|
447
473
|
@store_lifetime = store_lifetime
|
|
448
474
|
@timeout = timeout.to_f
|
|
449
475
|
@interval = flush_interval.to_f
|
|
450
476
|
@max_batch = max_batch.to_i.clamp(1, MAX_EVENTS_PER_REQUEST)
|
|
451
|
-
@logger = logger || StderrLogger.new
|
|
452
477
|
# Two environments' keys may share one Redis: their users must not mix.
|
|
453
478
|
@prefix = "recapi:#{Digest::SHA256.hexdigest(api_key)[0, 12]}:"
|
|
454
479
|
# Indirections the tests replace (clock, backoff sleep, jitter).
|
|
@@ -718,6 +743,7 @@ module Recapi
|
|
|
718
743
|
@send_lock = Mutex.new
|
|
719
744
|
@thread = nil
|
|
720
745
|
@failures = 0
|
|
746
|
+
@overflow_printed = false
|
|
721
747
|
@closed = false
|
|
722
748
|
@idle = []
|
|
723
749
|
@idle_lock = Mutex.new
|
|
@@ -777,6 +803,13 @@ module Recapi
|
|
|
777
803
|
def drop_overflow
|
|
778
804
|
dropped = @queue.size - MAX_QUEUE
|
|
779
805
|
@queue.shift(dropped)
|
|
806
|
+
# A full queue drops on every track through an outage: on $stderr, which
|
|
807
|
+
# the operator can't filter, one line until a send works is enough, and
|
|
808
|
+
# the "sending failed" lines keep showing the outage. A logger given gets
|
|
809
|
+
# them all. Runs under @mutex.
|
|
810
|
+
return if @limit_lost_lines && @overflow_printed
|
|
811
|
+
|
|
812
|
+
@overflow_printed = true
|
|
780
813
|
@logger.warn("recapi: event queue full, dropped the #{dropped} oldest events")
|
|
781
814
|
end
|
|
782
815
|
|
|
@@ -809,7 +842,7 @@ module Recapi
|
|
|
809
842
|
send_events(batch, retry_errors)
|
|
810
843
|
rescue StandardError => e
|
|
811
844
|
if e.is_a?(Error) && !e.retryable?
|
|
812
|
-
|
|
845
|
+
log_lost(e.code, "recapi: dropped #{batch.size} events: #{e.message}")
|
|
813
846
|
next
|
|
814
847
|
end
|
|
815
848
|
# Anything else keeps the batch, a bug of ours included: event ids
|
|
@@ -823,7 +856,10 @@ module Recapi
|
|
|
823
856
|
@logger.warn("recapi: sending #{batch.size} events failed, kept: #{e.message}")
|
|
824
857
|
return
|
|
825
858
|
end
|
|
826
|
-
@mutex.synchronize
|
|
859
|
+
@mutex.synchronize do
|
|
860
|
+
@failures = 0
|
|
861
|
+
@overflow_printed = false
|
|
862
|
+
end
|
|
827
863
|
end
|
|
828
864
|
end
|
|
829
865
|
end
|
|
@@ -850,7 +886,23 @@ module Recapi
|
|
|
850
886
|
detail = rejected.first(5).map do |r|
|
|
851
887
|
r.is_a?(Hash) && r["index"].is_a?(Integer) ? r.merge("event" => events[r["index"]]) : r
|
|
852
888
|
end
|
|
853
|
-
|
|
889
|
+
log_lost(:rejected, "recapi: #{rejected.size} event(s) rejected: #{JSON.generate(detail)}")
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# A revoked key refuses every batch and the sender runs on with no backoff,
|
|
893
|
+
# so $stderr gets one line a minute per kind (error code, or rejected
|
|
894
|
+
# events): one line per outage would have aged out of the log by the time
|
|
895
|
+
# someone looks. A logger given gets them all. Runs under @send_lock.
|
|
896
|
+
def log_lost(kind, message)
|
|
897
|
+
if @limit_lost_lines
|
|
898
|
+
now = @clock.call
|
|
899
|
+
last = @lost_printed[kind]
|
|
900
|
+
# abs: a clock set back must not mute the kind until it catches up.
|
|
901
|
+
return if last && (now - last).abs < LOST_LINE_MS
|
|
902
|
+
|
|
903
|
+
@lost_printed[kind] = now
|
|
904
|
+
end
|
|
905
|
+
@logger.error(message)
|
|
854
906
|
end
|
|
855
907
|
|
|
856
908
|
# Calls the block, retrying retryable errors with jittered exponential
|
|
@@ -1020,10 +1072,30 @@ module Recapi
|
|
|
1020
1072
|
@logger.error("recapi: store write failed: #{e.class}: #{e.message}")
|
|
1021
1073
|
end
|
|
1022
1074
|
|
|
1075
|
+
# The store, or our own in its place if it is an ActiveSupport cache that
|
|
1076
|
+
# loses state. Says so in one line, at start only. Any doubt, or a store
|
|
1077
|
+
# that fails here, means the store as given.
|
|
1078
|
+
def store_to_use(store)
|
|
1079
|
+
name = store.class.name
|
|
1080
|
+
if USE_OURS.key?(name)
|
|
1081
|
+
@logger.warn("recapi: #{name} #{USE_OURS[name]}, so recapi uses its own in-process store (10,000 users). " \
|
|
1082
|
+
"For several processes or servers, use Redis, Memcached or Solid Cache.")
|
|
1083
|
+
return MemoryStore.new
|
|
1084
|
+
end
|
|
1085
|
+
if name == FILE_STORE
|
|
1086
|
+
@logger.warn("recapi: #{name} isn't shared between servers, and frees an expired entry only when it is " \
|
|
1087
|
+
"read again or cleanup runs. Run Rails.cache.cleanup on a schedule; for several servers, " \
|
|
1088
|
+
"use Redis, Memcached or Solid Cache.")
|
|
1089
|
+
end
|
|
1090
|
+
store
|
|
1091
|
+
rescue StandardError
|
|
1092
|
+
store
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1023
1095
|
# What makes this store's write expire the entry. By class name, since the
|
|
1024
1096
|
# library requires neither gem. Any doubt (a subclass's own set, a wrapper
|
|
1025
|
-
# such as ConnectionPool::Wrapper) means no option,
|
|
1026
|
-
# customer's.
|
|
1097
|
+
# such as ConnectionPool::Wrapper, a store that fails here) means no option,
|
|
1098
|
+
# as for a store of the customer's.
|
|
1027
1099
|
def store_lifetime
|
|
1028
1100
|
klass = @store.class
|
|
1029
1101
|
if klass.public_method_defined?(:set)
|
|
@@ -1033,6 +1105,8 @@ module Recapi
|
|
|
1033
1105
|
else
|
|
1034
1106
|
{}
|
|
1035
1107
|
end
|
|
1108
|
+
rescue StandardError
|
|
1109
|
+
{}
|
|
1036
1110
|
end
|
|
1037
1111
|
|
|
1038
1112
|
# Redis clients have get/set/del, Rails.cache read/write/delete.
|