barkibu-kb 1.1.0 → 1.3.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/CHANGELOG.md +10 -1
- data/Gemfile.lock +19 -3
- data/README.md +80 -0
- data/barkibu-kb.gemspec +2 -0
- data/lib/barkibu-kb.rb +4 -0
- data/lib/kb/client.rb +44 -23
- data/lib/kb/instrumentation/datadog.rb +93 -0
- data/lib/kb/models/concerns/listable.rb +0 -2
- data/lib/kb/retry_policy.rb +88 -0
- data/lib/kb/version.rb +1 -1
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72ba3947460c029c6dee0dc965b1066b03b645d3bb6a891b291c2383b8d581e3
|
|
4
|
+
data.tar.gz: d0b5ca779b79f1e7017dbafca93376215faac264ec55210ef1af8f981f352ec5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d596248f6f49451a5a4bcb9ea1648b3175b3d8b1d7cbf0665082332841d63c32867e7f408d221f2a47ef7387e1b70836c74af5bfd8e5e37ae9536ea9b19ec42
|
|
7
|
+
data.tar.gz: e832f256a27bfbe905d6e122b81245370c932dad1801d5772608778851131c9da1e761817e164df8a91e5e770af79804cd2c0cfdc90310148048bd92ef761ea7
|
data/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
## [unreleased]
|
|
9
|
-
- See diff: https://github.com/barkibu/kb-ruby/compare/v1.
|
|
9
|
+
- See diff: https://github.com/barkibu/kb-ruby/compare/v1.3.0...HEAD
|
|
10
|
+
|
|
11
|
+
## [1.3.0]
|
|
12
|
+
- Retry transport failures once (`faraday-retry`, already in the Faraday 1.10 bundle, now an explicit dependency). `KB::RetryPolicy` decides by the underlying error, not the Faraday class: failures where the request never left (`Net::OpenTimeout`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `EADDRNOTAVAIL`, `SocketError`) retry for every verb; any other transport failure (read/write timeout, reset, EOF, TLS) retries for GET/HEAD only (and not when the call raised its own `read_timeout:`, so a 30s read isn't doubled); HTTP error responses never retry. New settings `KB.config.request.retries` (default 1, 0 disables) and `retry_interval` (default 0.1s, randomized up to 2x). Worst-case latency is now two attempts' worth of phase budgets.
|
|
13
|
+
- `request.kb_client` payload gains `retries` and `retry_errors` on retried calls; the Datadog subscriber tags them as `kb.retries` / `kb.retry_errors`. The event and span cover all attempts, so a call that succeeded on retry is not an error.
|
|
14
|
+
- `KB::Listable.all` (and so `PetParent.all`, `Pet.all`, `Breed.all`, `Product.all`, `Plan.all`, `Assessment.all`) now wraps `Faraday::ConnectionFailed` in `KB::Error` like every other model call, instead of re-raising it raw. Code rescuing `Faraday::ConnectionFailed` around `.all` must rescue `KB::Error` instead; none of Funnel, Global Admin or connected_health does.
|
|
15
|
+
|
|
16
|
+
## [1.2.0]
|
|
17
|
+
- `KB::Client` emits one `request.kb_client` `ActiveSupport::Notifications` event per KB call (`KB::Client::REQUEST_EVENT`), wrapping cache lookup, connect, TLS, write, read and parsing. Payload: `verb`, `path`, `base_url`, `cache_hit` (GET only), `status`, plus ActiveSupport's `exception`/`exception_object` when the call raised. Every public method now goes through one private `perform` seam; no behaviour change (same cache keys, params and error classes).
|
|
18
|
+
- Add an opt-in Datadog subscriber: `require 'kb/instrumentation/datadog'` + `KB::Instrumentation::Datadog.subscribe!` turns each event into a `kb.client.request` APM span, opened on event start and closed on finish so the tracer's Net::HTTP spans nest under it. The span inherits the app's service and stays there (no `span.kind:client`/`peer.service`, so it is not attributed to the knowledge-base service), tags `peer.hostname`, `kb.method`, `kb.cache_hit`, `http.status_code`, and uses low-cardinality resources (`GET /v1/pets/?/contracts`). Motivation: connect timeouts happen before `Net::HTTP#request`, so the Datadog Net::HTTP tracer never sees them and they were invisible on our dashboards. Works with `ddtrace` 1.x and `datadog` 2.x; the tracer stays the app's dependency.
|
|
10
19
|
|
|
11
20
|
## [1.1.0]
|
|
12
21
|
- Add `read_timeout:` to `KB::Client#request` to raise the read budget for a single call (e.g. `GET /v1/pets/birthdays`, whose server-side work runs for seconds). Connect and write budgets stay global; the override does not leak into later calls on the same connection.
|
data/Gemfile.lock
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
barkibu-kb (1.
|
|
4
|
+
barkibu-kb (1.3.0)
|
|
5
5
|
activemodel (>= 4.0.2)
|
|
6
6
|
activerecord
|
|
7
7
|
activesupport (>= 3.0.0)
|
|
8
8
|
dry-configurable (~> 0.9)
|
|
9
9
|
faraday
|
|
10
10
|
faraday-net_http (~> 1.0)
|
|
11
|
+
faraday-retry (~> 1.0)
|
|
11
12
|
faraday_middleware
|
|
12
13
|
i18n
|
|
13
|
-
barkibu-kb-fake (1.
|
|
14
|
-
barkibu-kb (= 1.
|
|
14
|
+
barkibu-kb-fake (1.3.0)
|
|
15
|
+
barkibu-kb (= 1.3.0)
|
|
15
16
|
countries
|
|
16
17
|
sinatra
|
|
17
18
|
webmock
|
|
@@ -44,12 +45,21 @@ GEM
|
|
|
44
45
|
base64 (0.3.0)
|
|
45
46
|
bigdecimal (4.1.2)
|
|
46
47
|
byebug (11.1.3)
|
|
48
|
+
cgi (0.5.2)
|
|
47
49
|
concurrent-ruby (1.3.7)
|
|
48
50
|
connection_pool (3.0.2)
|
|
49
51
|
countries (5.3.1)
|
|
50
52
|
unaccent (~> 0.3)
|
|
51
53
|
crack (0.4.5)
|
|
52
54
|
rexml
|
|
55
|
+
datadog (2.43.0)
|
|
56
|
+
cgi
|
|
57
|
+
datadog-ruby_core_source (~> 3.5, >= 3.5.5)
|
|
58
|
+
libdatadog (~> 40.0.0.2.0)
|
|
59
|
+
libddwaf (~> 1.30.0.0.0)
|
|
60
|
+
logger
|
|
61
|
+
msgpack
|
|
62
|
+
datadog-ruby_core_source (3.5.5)
|
|
53
63
|
diff-lcs (1.4.4)
|
|
54
64
|
docile (1.4.0)
|
|
55
65
|
drb (2.2.3)
|
|
@@ -84,14 +94,19 @@ GEM
|
|
|
84
94
|
faraday-retry (1.0.3)
|
|
85
95
|
faraday_middleware (1.2.0)
|
|
86
96
|
faraday (~> 1.0)
|
|
97
|
+
ffi (1.17.4)
|
|
87
98
|
hashdiff (1.0.1)
|
|
88
99
|
i18n (1.15.2)
|
|
89
100
|
concurrent-ruby (~> 1.0)
|
|
90
101
|
json (2.20.0)
|
|
102
|
+
libdatadog (40.0.0.2.0)
|
|
103
|
+
libddwaf (1.30.0.0.2)
|
|
104
|
+
ffi (~> 1.0)
|
|
91
105
|
logger (1.7.0)
|
|
92
106
|
minitest (6.0.6)
|
|
93
107
|
drb (~> 2.0)
|
|
94
108
|
prism (~> 1.5)
|
|
109
|
+
msgpack (1.8.5)
|
|
95
110
|
multipart-post (2.3.0)
|
|
96
111
|
mustermann (3.0.0)
|
|
97
112
|
ruby2_keywords (~> 0.0.1)
|
|
@@ -173,6 +188,7 @@ DEPENDENCIES
|
|
|
173
188
|
bigdecimal
|
|
174
189
|
bundler
|
|
175
190
|
byebug
|
|
191
|
+
datadog (~> 2.0)
|
|
176
192
|
rake (>= 12.3.3)
|
|
177
193
|
rspec (~> 3.0)
|
|
178
194
|
rubocop
|
data/README.md
CHANGED
|
@@ -74,6 +74,86 @@ and write budgets stay global:
|
|
|
74
74
|
KB::Pet.kb_client.request('birthdays', filters: { month: 9, day: 22, size: 1000 }, read_timeout: 30)
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
#### Retries
|
|
78
|
+
|
|
79
|
+
The client retries a failed call once when the failure is in the transport, never
|
|
80
|
+
when KB answered with an HTTP error. Which calls retry depends on whether the
|
|
81
|
+
request can have reached KB (`KB::RetryPolicy`):
|
|
82
|
+
|
|
83
|
+
| Failure | Retried for |
|
|
84
|
+
|---|---|
|
|
85
|
+
| Never sent: connect/TLS timeout (`Net::OpenTimeout`), connection refused, host/network unreachable, DNS failure | every verb, POST included |
|
|
86
|
+
| Maybe sent: read/write timeout, connection reset, EOF, TLS error mid-stream | GET and HEAD only |
|
|
87
|
+
| HTTP 4xx/5xx | never |
|
|
88
|
+
|
|
89
|
+
PUT and DELETE are not retried on "maybe sent" failures: `upsert` and
|
|
90
|
+
`PetParent#merge!` are PUTs whose second run is not a no-op on KB's side, and a
|
|
91
|
+
repeated DELETE would turn a success into a 404. A call that raised its own read
|
|
92
|
+
budget (`read_timeout:` on `KB::Client#request`) is not retried on "maybe sent"
|
|
93
|
+
failures either, so a 30s birthdays read can't become 60s; failures that never
|
|
94
|
+
reached KB are still retried.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# config/initializers/kb_ruby.rb
|
|
98
|
+
KB.config.request.retries = 1 # default; 0 disables retries
|
|
99
|
+
KB.config.request.retry_interval = 0.1 # default, seconds; each wait is 1x-2x this
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Like the timeouts, these are read when a client builds its connection, i.e. on
|
|
103
|
+
its first call, so set them in an initializer.
|
|
104
|
+
|
|
105
|
+
Worst case, a call now takes two attempts' worth of phase budgets plus the
|
|
106
|
+
interval, e.g. a GET that read-times-out twice takes about 2 x (1 + 3 + 5)s with
|
|
107
|
+
the default timeouts.
|
|
108
|
+
|
|
109
|
+
#### Instrumentation
|
|
110
|
+
|
|
111
|
+
Every KB call emits one `request.kb_client` event through
|
|
112
|
+
`ActiveSupport::Notifications`, wrapping the whole call: cache lookup, TCP
|
|
113
|
+
connect, TLS, write, read and JSON parsing. The payload carries `verb`, `path`,
|
|
114
|
+
`base_url`, `cache_hit` (GET calls only), `status` (when a response arrived),
|
|
115
|
+
`retries` and `retry_errors` (only when the call was retried: the count, and the
|
|
116
|
+
underlying error class of each failed attempt, e.g. `["Net::OpenTimeout"]`) and
|
|
117
|
+
ActiveSupport's `exception` / `exception_object` when the call raised. The event
|
|
118
|
+
covers the whole call including retries, so `exception` is set only when every
|
|
119
|
+
attempt failed. Subscribe to it for logging, metrics or anything else:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
ActiveSupport::Notifications.subscribe(KB::Client::REQUEST_EVENT) do |event|
|
|
123
|
+
Rails.logger.info("KB #{event.payload[:verb]} #{event.payload[:path]} #{event.duration.round}ms")
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
##### Datadog
|
|
128
|
+
|
|
129
|
+
A ready-made subscriber turns each event into a `kb.client.request` APM span.
|
|
130
|
+
Opt in from the app's Datadog initializer, after `Datadog.configure`. Works with
|
|
131
|
+
both `ddtrace` 1.x and `datadog` 2.x; the tracer gem is the app's dependency.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# config/initializers/datadog_tracer.rb
|
|
135
|
+
Datadog.configure { |c| ... }
|
|
136
|
+
|
|
137
|
+
require 'kb/instrumentation/datadog'
|
|
138
|
+
KB::Instrumentation::Datadog.subscribe!
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The span opens when the event starts and closes when it finishes, so the
|
|
142
|
+
tracer's own Net::HTTP spans nest under it. It inherits the app's service
|
|
143
|
+
(`c.service`), so nothing new appears in the APM service list, and it carries no
|
|
144
|
+
`span.kind:client` or `peer.service`, so Datadog does not attribute it to the
|
|
145
|
+
knowledge-base service either: it measures the client's whole call, not a KB
|
|
146
|
+
operation. Resources are low-cardinality (`GET /v1/pets/birthdays`,
|
|
147
|
+
`GET /v1/pets/?/contracts`). Tags: `peer.hostname` (the KB host used),
|
|
148
|
+
`kb.method`, `kb.cache_hit` (GET calls only), `http.status_code`, `kb.retries`
|
|
149
|
+
and `kb.retry_errors` (retried calls only), plus the standard
|
|
150
|
+
`error.type`/`error.message` when the call raises. A span with `kb.retries` and
|
|
151
|
+
no error is a failure the retry absorbed.
|
|
152
|
+
|
|
153
|
+
Why not rely on the Net::HTTP tracer alone: faraday-net_http opens the socket
|
|
154
|
+
before `Net::HTTP#request`, the method that tracer patches, so a connect timeout
|
|
155
|
+
produces no http span at all. This span sees every phase.
|
|
156
|
+
|
|
77
157
|
### Exposed Entities
|
|
78
158
|
|
|
79
159
|
#### Pet Parent 🧍🏾
|
data/barkibu-kb.gemspec
CHANGED
|
@@ -39,6 +39,7 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
spec.add_dependency 'dry-configurable', '~> 0.9'
|
|
40
40
|
spec.add_development_dependency 'bundler'
|
|
41
41
|
spec.add_development_dependency 'byebug'
|
|
42
|
+
spec.add_development_dependency 'datadog', '~> 2.0'
|
|
42
43
|
spec.add_development_dependency 'rake', '>= 12.3.3'
|
|
43
44
|
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
44
45
|
spec.add_development_dependency 'rubocop'
|
|
@@ -53,5 +54,6 @@ Gem::Specification.new do |spec|
|
|
|
53
54
|
spec.add_runtime_dependency 'faraday'
|
|
54
55
|
spec.add_runtime_dependency 'faraday-net_http', '~> 1.0'
|
|
55
56
|
spec.add_runtime_dependency 'faraday_middleware'
|
|
57
|
+
spec.add_runtime_dependency 'faraday-retry', '~> 1.0'
|
|
56
58
|
spec.add_runtime_dependency 'i18n'
|
|
57
59
|
end
|
data/lib/barkibu-kb.rb
CHANGED
|
@@ -21,6 +21,9 @@ module KB
|
|
|
21
21
|
setting :connect_timeout, default: 1
|
|
22
22
|
setting :write_timeout, default: 3
|
|
23
23
|
setting :read_timeout, default: 5
|
|
24
|
+
# Retries after a transport failure, per KB::RetryPolicy. 0 disables retries.
|
|
25
|
+
setting :retries, default: 1
|
|
26
|
+
setting :retry_interval, default: 0.1
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
@@ -29,6 +32,7 @@ require 'kb/inflections'
|
|
|
29
32
|
require 'kb/cache'
|
|
30
33
|
require 'kb/client_resolver'
|
|
31
34
|
require 'kb/errors'
|
|
35
|
+
require 'kb/retry_policy'
|
|
32
36
|
require 'kb/client'
|
|
33
37
|
|
|
34
38
|
require 'kb/concerns'
|
data/lib/kb/client.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
module KB
|
|
2
2
|
class Client
|
|
3
|
+
# Emitted once per KB call, wrapping cache lookup and the HTTP request.
|
|
4
|
+
# Payload: verb, path, base_url, cache_hit (GET only), status (when a response arrived),
|
|
5
|
+
# retries / retry_errors (only when the call was retried, see KB::RetryPolicy),
|
|
6
|
+
# plus ActiveSupport's exception/exception_object when the call raised.
|
|
7
|
+
REQUEST_EVENT = 'request.kb_client'.freeze
|
|
8
|
+
|
|
3
9
|
attr_reader :api_key, :base_url
|
|
4
10
|
|
|
5
11
|
def initialize(base_url, api_key: ENV['KB_API_KEY'])
|
|
@@ -11,47 +17,38 @@ module KB
|
|
|
11
17
|
# for the few endpoints whose server-side work legitimately runs for seconds
|
|
12
18
|
# (e.g. GET /v1/pets/birthdays). Connect and write budgets stay global.
|
|
13
19
|
def request(sub_path, filters: nil, method: :get, read_timeout: nil)
|
|
14
|
-
|
|
15
|
-
return connection.public_send(method, sub_path, attributes_to_json(filters), &options).body if method != :get
|
|
20
|
+
return perform(method, sub_path, attributes_to_json(filters), read_timeout: read_timeout) if method != :get
|
|
16
21
|
|
|
17
22
|
cache_key = "#{@base_url}/#{sub_path}/#{(filters || {}).sort.to_h}"
|
|
18
|
-
|
|
19
|
-
connection.public_send(method, sub_path, filters, &options).body
|
|
20
|
-
end
|
|
23
|
+
perform(:get, sub_path, filters, cache_key: cache_key, read_timeout: read_timeout)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def all(filters = {})
|
|
24
|
-
cache_key
|
|
25
|
-
|
|
26
|
-
KB::Cache.fetch(cache_key) do
|
|
27
|
-
connection.get('', attributes_case_transform(filters)).body
|
|
28
|
-
end
|
|
27
|
+
perform(:get, '', attributes_case_transform(filters), cache_key: "#{@base_url}/#{filters.sort.to_h}")
|
|
29
28
|
end
|
|
30
29
|
|
|
31
30
|
def find(key, params = {})
|
|
32
31
|
raise Faraday::ResourceNotFound, {} if key.blank?
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
connection.get(key, attributes_case_transform(params)).body
|
|
36
|
-
end
|
|
33
|
+
perform(:get, key, attributes_case_transform(params), cache_key: "#{@base_url}/#{key}")
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
def create(attributes)
|
|
40
|
-
|
|
37
|
+
perform(:post, '', attributes_to_json(attributes))
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
def update(key, attributes)
|
|
44
41
|
clear_cache_for(key)
|
|
45
|
-
|
|
42
|
+
perform(:patch, key.to_s, attributes_to_json(attributes))
|
|
46
43
|
end
|
|
47
44
|
|
|
48
45
|
def destroy(key)
|
|
49
46
|
clear_cache_for(key)
|
|
50
|
-
|
|
47
|
+
perform(:delete, key.to_s)
|
|
51
48
|
end
|
|
52
49
|
|
|
53
50
|
def upsert(attributes)
|
|
54
|
-
|
|
51
|
+
perform(:put, '', attributes_to_json(attributes))
|
|
55
52
|
end
|
|
56
53
|
|
|
57
54
|
def clear_cache_for(key)
|
|
@@ -60,6 +57,35 @@ module KB
|
|
|
60
57
|
|
|
61
58
|
private
|
|
62
59
|
|
|
60
|
+
# Every public method ends up here, so this is the one place a KB call is
|
|
61
|
+
# observable as a whole: cache lookup, connect, TLS, write, read, parse.
|
|
62
|
+
def perform(verb, path, payload = nil, cache_key: nil, read_timeout: nil)
|
|
63
|
+
event = { verb: verb, path: path, base_url: base_url }
|
|
64
|
+
ActiveSupport::Notifications.instrument(REQUEST_EVENT, event) do
|
|
65
|
+
if cache_key
|
|
66
|
+
event[:cache_hit] = true
|
|
67
|
+
KB::Cache.fetch(cache_key) do
|
|
68
|
+
event[:cache_hit] = false
|
|
69
|
+
http(event, payload, read_timeout)
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
http(event, payload, read_timeout)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def http(event, payload, read_timeout)
|
|
78
|
+
response = connection.public_send(event[:verb], event[:path], payload) do |req|
|
|
79
|
+
req.options.read_timeout = read_timeout if read_timeout
|
|
80
|
+
RetryPolicy.track(req, event, read_timeout)
|
|
81
|
+
end
|
|
82
|
+
event[:status] = response.status
|
|
83
|
+
response.body
|
|
84
|
+
rescue Faraday::ClientError, Faraday::ServerError => e
|
|
85
|
+
event[:status] = e.response && e.response[:status]
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
|
|
63
89
|
def headers
|
|
64
90
|
{
|
|
65
91
|
'Content-Type': 'application/json',
|
|
@@ -79,6 +105,7 @@ module KB
|
|
|
79
105
|
|
|
80
106
|
def connection
|
|
81
107
|
@connection ||= Faraday.new(url: base_url, headers: headers, request: request_timeouts) do |conn|
|
|
108
|
+
conn.request :retry, RetryPolicy.middleware_options
|
|
82
109
|
conn.response :json
|
|
83
110
|
conn.response :raise_error
|
|
84
111
|
if KB.config.log_level == :debugger
|
|
@@ -90,12 +117,6 @@ module KB
|
|
|
90
117
|
end
|
|
91
118
|
end
|
|
92
119
|
|
|
93
|
-
def request_options(read_timeout)
|
|
94
|
-
return nil if read_timeout.nil?
|
|
95
|
-
|
|
96
|
-
->(req) { req.options.read_timeout = read_timeout }
|
|
97
|
-
end
|
|
98
|
-
|
|
99
120
|
def request_timeouts
|
|
100
121
|
{
|
|
101
122
|
open_timeout: KB.config.request.connect_timeout,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'active_support/notifications'
|
|
3
|
+
|
|
4
|
+
module KB
|
|
5
|
+
module Instrumentation
|
|
6
|
+
# Opt-in Datadog APM tracing for every KB call, as a subscriber to the
|
|
7
|
+
# client's `request.kb_client` notification.
|
|
8
|
+
#
|
|
9
|
+
# # config/initializers/datadog_tracer.rb, after Datadog.configure
|
|
10
|
+
# require 'kb/instrumentation/datadog'
|
|
11
|
+
# KB::Instrumentation::Datadog.subscribe!
|
|
12
|
+
#
|
|
13
|
+
# One `kb.client.request` span per call, opened when the event starts and
|
|
14
|
+
# closed when it finishes, so it wraps cache lookup, TCP connect, TLS,
|
|
15
|
+
# write, read and JSON parsing, and the tracer's own Net::HTTP spans nest
|
|
16
|
+
# under it. No service is given, so the span inherits the app's, and it stays
|
|
17
|
+
# there: the KB host is a plain `peer.hostname` tag, with no `span.kind:client`
|
|
18
|
+
# or `peer.service` that would attribute it to KB. Works with
|
|
19
|
+
# `ddtrace` 1.x and `datadog` 2.x; the tracer gem is the app's dependency.
|
|
20
|
+
module Datadog
|
|
21
|
+
OPERATION = 'kb.client.request'.freeze
|
|
22
|
+
# Path segments that are identifiers, collapsed to `?` so resources stay
|
|
23
|
+
# low-cardinality: `GET /v1/pets/?/contracts` rather than one per pet.
|
|
24
|
+
IDENTIFIER_SEGMENT = /\A(?:\h{8}-\h{4}-\h{4}-\h{4}-\h{12}|\d+)\z/.freeze
|
|
25
|
+
SPAN_KEY = :datadog_span
|
|
26
|
+
|
|
27
|
+
class TracerMissing < StandardError; end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def subscribe!
|
|
31
|
+
unless defined?(::Datadog::Tracing)
|
|
32
|
+
raise TracerMissing, "Datadog tracing is not loaded; require 'ddtrace' or 'datadog' first"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return @subscriber if @subscriber
|
|
36
|
+
|
|
37
|
+
@subscriber = ActiveSupport::Notifications.subscribe(KB::Client::REQUEST_EVENT, Subscriber.new)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def unsubscribe!
|
|
41
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
|
|
42
|
+
@subscriber = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def subscribed?
|
|
46
|
+
!@subscriber.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resource_for(base_url, verb, path)
|
|
50
|
+
segments = (URI(base_url).path.split('/') + path.to_s.split('/')).reject(&:empty?)
|
|
51
|
+
template = segments.map { |segment| segment.match?(IDENTIFIER_SEGMENT) ? '?' : segment }
|
|
52
|
+
"#{verb.to_s.upcase} /#{template.join('/')}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class Subscriber
|
|
57
|
+
def start(_name, _id, payload)
|
|
58
|
+
span = ::Datadog::Tracing.trace(OPERATION, type: 'http',
|
|
59
|
+
resource: Datadog.resource_for(payload[:base_url], payload[:verb],
|
|
60
|
+
payload[:path]))
|
|
61
|
+
# No span.kind:client / peer.service on purpose: the span covers the
|
|
62
|
+
# client's whole call (cache lookup, connect, parse), so it must not be
|
|
63
|
+
# inferred onto the knowledge-base service page as one of KB's operations.
|
|
64
|
+
span.set_tag('peer.hostname', URI(payload[:base_url]).host)
|
|
65
|
+
span.set_tag('kb.method', payload[:verb].to_s.upcase)
|
|
66
|
+
payload[SPAN_KEY] = span
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish(_name, _id, payload)
|
|
70
|
+
span = payload.delete(SPAN_KEY)
|
|
71
|
+
return unless span
|
|
72
|
+
|
|
73
|
+
span.set_tag('kb.cache_hit', payload[:cache_hit].to_s) if payload.key?(:cache_hit)
|
|
74
|
+
span.set_tag('http.status_code', payload[:status].to_s) if payload[:status]
|
|
75
|
+
tag_retries(span, payload)
|
|
76
|
+
span.set_error(payload[:exception_object]) if payload[:exception_object]
|
|
77
|
+
span.finish
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Only on retried calls: `kb.retries` (numeric) and the distinct underlying
|
|
83
|
+
# errors that triggered them, e.g. `Net::OpenTimeout`.
|
|
84
|
+
def tag_retries(span, payload)
|
|
85
|
+
return unless payload[:retries]
|
|
86
|
+
|
|
87
|
+
span.set_tag('kb.retries', payload[:retries])
|
|
88
|
+
span.set_tag('kb.retry_errors', payload[:retry_errors].uniq.join(','))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
require 'faraday/retry'
|
|
4
|
+
|
|
5
|
+
module KB
|
|
6
|
+
# Decides which failed KB calls the client retries.
|
|
7
|
+
#
|
|
8
|
+
# Two classes of transport failure, told apart by the underlying Ruby error
|
|
9
|
+
# rather than the Faraday class (adapters disagree on the Faraday class: the
|
|
10
|
+
# net_http adapter wraps Net::OpenTimeout as ConnectionFailed, the persistent
|
|
11
|
+
# one as TimeoutError):
|
|
12
|
+
#
|
|
13
|
+
# - never sent: TCP connect / TLS handshake did not complete, so KB cannot have
|
|
14
|
+
# seen the request. Safe to retry for every verb, POST included.
|
|
15
|
+
# - maybe sent: anything else the transport raises (read/write timeout, reset,
|
|
16
|
+
# EOF, TLS error mid-stream). KB may have processed it, so only GET/HEAD are
|
|
17
|
+
# retried. PUT/DELETE are left out on purpose: `upsert` and `merge!` are PUTs
|
|
18
|
+
# whose second run is not a no-op on KB's side, and a repeated DELETE would
|
|
19
|
+
# turn a success into a 404.
|
|
20
|
+
# A call that raised its own read budget (`read_timeout:`, e.g. 30s for
|
|
21
|
+
# birthdays) is not retried on these either, so its worst case isn't doubled.
|
|
22
|
+
#
|
|
23
|
+
# HTTP responses (4xx/5xx) are never retried: KB answered.
|
|
24
|
+
module RetryPolicy
|
|
25
|
+
NOT_SENT_ERRORS = [
|
|
26
|
+
Net::OpenTimeout,
|
|
27
|
+
Errno::ECONNREFUSED,
|
|
28
|
+
Errno::EHOSTUNREACH,
|
|
29
|
+
Errno::ENETUNREACH,
|
|
30
|
+
Errno::EADDRNOTAVAIL,
|
|
31
|
+
SocketError # DNS resolution
|
|
32
|
+
].freeze
|
|
33
|
+
TRANSPORT_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze
|
|
34
|
+
MAYBE_SENT_VERBS = %i[get head].freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def retry?(verb, error, own_read_budget: false)
|
|
39
|
+
return false unless TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
40
|
+
|
|
41
|
+
not_sent?(error) || (MAYBE_SENT_VERBS.include?(verb) && !own_read_budget)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def not_sent?(error)
|
|
45
|
+
cause = root_cause(error)
|
|
46
|
+
NOT_SENT_ERRORS.any? { |klass| cause.is_a?(klass) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The Ruby error behind a Faraday error. `wrapped_exception` is Faraday's own
|
|
50
|
+
# explicit link to it (Ruby's `cause` is only whatever was being rescued at
|
|
51
|
+
# the raise, usually the same object). Faraday's adapters wrap the Ruby error
|
|
52
|
+
# one level deep, so one level is enough.
|
|
53
|
+
def root_cause(error)
|
|
54
|
+
(error.respond_to?(:wrapped_exception) && error.wrapped_exception) || error.cause || error
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Options for faraday-retry's middleware, read from KB.config.request.
|
|
58
|
+
def middleware_options
|
|
59
|
+
{
|
|
60
|
+
max: KB.config.request.retries,
|
|
61
|
+
interval: KB.config.request.retry_interval,
|
|
62
|
+
interval_randomness: 1, # 1x-2x the interval, so a burst of callers doesn't retry in lockstep
|
|
63
|
+
exceptions: TRANSPORT_ERRORS,
|
|
64
|
+
methods: [], # always ask retry_if
|
|
65
|
+
retry_if: lambda do |env, error|
|
|
66
|
+
retry?(env[:method], error, own_read_budget: env[:request].context&.dig(:kb_own_read_budget))
|
|
67
|
+
end,
|
|
68
|
+
retry_block: ->(env, _options, _retries_left, error) { record(env, error) }
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Hands the request.kb_client event payload (and whether the call set its own
|
|
73
|
+
# read budget) to the middleware through the request context, so each retry
|
|
74
|
+
# is reported on the call's own event.
|
|
75
|
+
def track(request, event, read_timeout = nil)
|
|
76
|
+
tracking = { kb_event: event, kb_own_read_budget: !read_timeout.nil? }
|
|
77
|
+
request.options.context = (request.options.context || {}).merge(tracking)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def record(env, error)
|
|
81
|
+
event = env[:request].context&.dig(:kb_event)
|
|
82
|
+
return unless event
|
|
83
|
+
|
|
84
|
+
event[:retries] = event.fetch(:retries, 0) + 1
|
|
85
|
+
(event[:retry_errors] ||= []) << root_cause(error).class.name
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/kb/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: barkibu-kb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Léo Figea
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: datadog
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.0'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: rake
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -247,6 +261,20 @@ dependencies:
|
|
|
247
261
|
- - ">="
|
|
248
262
|
- !ruby/object:Gem::Version
|
|
249
263
|
version: '0'
|
|
264
|
+
- !ruby/object:Gem::Dependency
|
|
265
|
+
name: faraday-retry
|
|
266
|
+
requirement: !ruby/object:Gem::Requirement
|
|
267
|
+
requirements:
|
|
268
|
+
- - "~>"
|
|
269
|
+
- !ruby/object:Gem::Version
|
|
270
|
+
version: '1.0'
|
|
271
|
+
type: :runtime
|
|
272
|
+
prerelease: false
|
|
273
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
274
|
+
requirements:
|
|
275
|
+
- - "~>"
|
|
276
|
+
- !ruby/object:Gem::Version
|
|
277
|
+
version: '1.0'
|
|
250
278
|
- !ruby/object:Gem::Dependency
|
|
251
279
|
name: i18n
|
|
252
280
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -313,6 +341,7 @@ files:
|
|
|
313
341
|
- lib/kb/fake/bounded_context/pet_family/products.rb
|
|
314
342
|
- lib/kb/fake/bounded_context/rest_resource.rb
|
|
315
343
|
- lib/kb/inflections.rb
|
|
344
|
+
- lib/kb/instrumentation/datadog.rb
|
|
316
345
|
- lib/kb/models.rb
|
|
317
346
|
- lib/kb/models/assessment.rb
|
|
318
347
|
- lib/kb/models/base_model.rb
|
|
@@ -337,6 +366,7 @@ files:
|
|
|
337
366
|
- lib/kb/models/referral.rb
|
|
338
367
|
- lib/kb/models/search_result.rb
|
|
339
368
|
- lib/kb/models/symptom.rb
|
|
369
|
+
- lib/kb/retry_policy.rb
|
|
340
370
|
- lib/kb/type/array_of_conditions_type.rb
|
|
341
371
|
- lib/kb/type/array_of_strings_type.rb
|
|
342
372
|
- lib/kb/type/array_of_symptoms_type.rb
|