octri 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +52 -17
- data/lib/octri.rb +147 -6
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15e020993bd573362db0191b73266111e568d1e12d0977b2ce1b82c85c0bd04d
|
|
4
|
+
data.tar.gz: a51dd16d8ff052a321cd2dbcd9701ca0a3f6ea5e73874385859c3a90720bf580
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0109372656982385c8ed24a765f5e0b6235f2a24636276e8a70bef540350f09f55869c16bc412b8d17f83f51695bbd6b34d995d8bb5e2abdbdfb417b6d0cdadc'
|
|
7
|
+
data.tar.gz: 75d97ab367c60d668d95446052ea5b6e85f17b870d76b6f0893ad69e089935e4935ad7ea7f6d104df626f4438efefd2329a4d0c8cf29dedc7974d2ba6c3bf936
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
- Payloads are now scrubbed before they are sent. Values under keys that name a
|
|
6
|
+
credential (`password`, `secret`, `token`, `apiKey`, `authorization`,
|
|
7
|
+
`cookie`, `ssn` and the rest) are replaced with `[redacted]` at any depth, and
|
|
8
|
+
free text is swept for bearer tokens, JWTs, Luhn-valid card numbers and email
|
|
9
|
+
addresses.
|
|
10
|
+
- `add_scrub_fields` adds your own key names to that list.
|
|
11
|
+
- `set_before_send` hands you each payload before it goes out; return
|
|
12
|
+
`nil` to drop the event. Redaction runs after the hook.
|
|
13
|
+
- The `user` field keeps the identity you set, since that is the point of it.
|
|
14
|
+
Credential-shaped keys inside it are still redacted.
|
|
15
|
+
|
|
16
|
+
## 1.0.1
|
|
17
|
+
|
|
18
|
+
- Source context is now read only for frames in your own code, skips files
|
|
19
|
+
over 512 KB, and keeps a bounded cache, so a deep or unusual stack cannot
|
|
20
|
+
pull dependency source into a report or grow memory without limit.
|
|
21
|
+
|
|
22
|
+
## 1.0.0
|
|
23
|
+
|
|
24
|
+
First public release.
|
|
25
|
+
|
|
26
|
+
- Error reporting with original source context for each in-app stack frame.
|
|
27
|
+
- Request and sub-span timing, drawn as a waterfall in the dashboard.
|
|
28
|
+
- W3C `traceparent` propagation, so a server error links to the client SDK
|
|
29
|
+
error for the same request.
|
|
30
|
+
- Standalone events with idempotent, best-effort delivery.
|
|
31
|
+
- Rack middleware for Rack and Rails, plus automatic instrumentation of outbound HTTP.
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
or Rails with original-source context per stack frame, time every request and any
|
|
5
5
|
sub-span you open into a waterfall, and join each server error to the client SDK
|
|
6
6
|
error for the same request through the W3C `traceparent` header. In the
|
|
7
|
-
dashboard you see the full client
|
|
7
|
+
dashboard you see the full client and server stack under one trace.
|
|
8
8
|
|
|
9
9
|
Octri turns an OpenAPI spec into a documentation site, client SDKs for ten
|
|
10
10
|
languages, an MCP server your AI assistant can call, and monitoring for the
|
|
@@ -70,7 +70,7 @@ Octri.instrument(cache, [:get, :set], op: "cache")
|
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
Every instrumented call (and every outbound HTTP request) becomes a sub-span
|
|
73
|
-
under the current request
|
|
73
|
+
under the current request, with no per-call code. Calls to your monitoring backend
|
|
74
74
|
are never traced (no feedback loop).
|
|
75
75
|
|
|
76
76
|
## Sub-spans (where time goes)
|
|
@@ -86,7 +86,7 @@ value = cache.read(key)
|
|
|
86
86
|
s.finish
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
`op` ("db", "cache", "http",
|
|
89
|
+
`op` ("db", "cache", "http", and so on) color-codes the waterfall; nested `Octri.span`
|
|
90
90
|
calls nest correctly.
|
|
91
91
|
|
|
92
92
|
## Manual error capture
|
|
@@ -102,6 +102,39 @@ end
|
|
|
102
102
|
|
|
103
103
|
---
|
|
104
104
|
|
|
105
|
+
## What gets redacted
|
|
106
|
+
|
|
107
|
+
Payloads are scrubbed on the way out, so a credential that ended up in a log
|
|
108
|
+
line or a context object never reaches the dashboard.
|
|
109
|
+
|
|
110
|
+
Any key whose name looks like a credential (`password`, `secret`, `token`,
|
|
111
|
+
`apiKey`, `authorization`, `cookie`, `ssn` and the rest of the usual list) has
|
|
112
|
+
its value replaced with `[redacted]`, at any depth. Matching ignores case and
|
|
113
|
+
separators, so `api_key`, `apiKey` and `X-API-KEY` are all the same key.
|
|
114
|
+
|
|
115
|
+
Free text is swept too: the message, an error message and its stack, and
|
|
116
|
+
anything else you send as a string. Bearer tokens, JWTs, card numbers and email
|
|
117
|
+
addresses come out as `[redacted]`. A card number has to pass the Luhn check
|
|
118
|
+
first, so an order number or a timestamp survives.
|
|
119
|
+
|
|
120
|
+
`user` is the exception. It is the field you fill with an identity on purpose,
|
|
121
|
+
so `user.email` is reported exactly as you set it. Credential-shaped keys inside
|
|
122
|
+
it are still redacted.
|
|
123
|
+
|
|
124
|
+
Add your own key names:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Octri.add_scrub_fields("account_number", "otp")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or take the payload yourself, and return `nil` to drop the event:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Octri.set_before_send { |payload| payload[:path] == "/health" ? nil : payload }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Redaction runs after your hook, so a hook cannot leak a credential by accident.
|
|
137
|
+
|
|
105
138
|
## The rest of Octri
|
|
106
139
|
|
|
107
140
|
| Product | What it does |
|
|
@@ -113,19 +146,21 @@ end
|
|
|
113
146
|
|
|
114
147
|
### Monitoring runtimes
|
|
115
148
|
|
|
116
|
-
[Node](https://github.com/octridev/octri-node)
|
|
117
|
-
[Python](https://github.com/octridev/octri-python)
|
|
118
|
-
[Go](https://github.com/octridev/octri-go)
|
|
119
|
-
[Ruby](https://github.com/octridev/octri-ruby)
|
|
120
|
-
[Rust](https://github.com/octridev/octri-rust)
|
|
121
|
-
[PHP](https://github.com/octridev/octri-php)
|
|
122
|
-
[Java](https://github.com/octridev/octri-java)
|
|
123
|
-
[Kotlin](https://github.com/octridev/octri-kotlin)
|
|
124
|
-
[Swift](https://github.com/octridev/octri-swift)
|
|
125
|
-
[Dart](https://github.com/octridev/octri-dart)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
[
|
|
149
|
+
- [Node](https://github.com/octridev/octri-node)
|
|
150
|
+
- [Python](https://github.com/octridev/octri-python)
|
|
151
|
+
- [Go](https://github.com/octridev/octri-go)
|
|
152
|
+
- [Ruby](https://github.com/octridev/octri-ruby)
|
|
153
|
+
- [Rust](https://github.com/octridev/octri-rust)
|
|
154
|
+
- [PHP](https://github.com/octridev/octri-php)
|
|
155
|
+
- [Java](https://github.com/octridev/octri-java)
|
|
156
|
+
- [Kotlin](https://github.com/octridev/octri-kotlin)
|
|
157
|
+
- [Swift](https://github.com/octridev/octri-swift)
|
|
158
|
+
- [Dart](https://github.com/octridev/octri-dart)
|
|
159
|
+
|
|
160
|
+
### More
|
|
161
|
+
|
|
162
|
+
- [Documentation](https://docs.octri.dev/docs)
|
|
163
|
+
- [Pricing](https://octri.dev/pricing)
|
|
164
|
+
- [Changelog](https://docs.octri.dev/changelog)
|
|
130
165
|
|
|
131
166
|
MIT licensed.
|
data/lib/octri.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Octri
|
|
3
|
+
# Octri: server-side error monitoring for Ruby backends.
|
|
4
4
|
#
|
|
5
5
|
# Add it to your live API; it reports backend errors to your Octri monitoring
|
|
6
6
|
# project (with original-source context per stack frame) and links each one to
|
|
7
|
-
# the client SDK error for the same request via the W3C `traceparent` header
|
|
7
|
+
# the client SDK error for the same request via the W3C `traceparent` header,
|
|
8
8
|
# so the dashboard shows the full client -> server stack under one trace. It also
|
|
9
9
|
# times requests (and any sub-spans you open) into the request waterfall.
|
|
10
10
|
#
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
# use Octri::Rack
|
|
15
15
|
|
|
16
16
|
require "securerandom"
|
|
17
|
+
require "set"
|
|
17
18
|
require "net/http"
|
|
18
19
|
require "uri"
|
|
19
20
|
require "json"
|
|
@@ -24,10 +25,31 @@ module Octri
|
|
|
24
25
|
Trace = Struct.new(:trace_id, :parent_span_id)
|
|
25
26
|
|
|
26
27
|
CONTEXT_LINES = 5
|
|
28
|
+
MAX_SOURCE_BYTES = 512 * 1024
|
|
29
|
+
MAX_CACHED_SOURCES = 256
|
|
27
30
|
REQUEST_TIMEOUT_SECONDS = 5
|
|
28
31
|
MAX_IDEMPOTENCY_KEY_LENGTH = 256
|
|
29
32
|
TRACEPARENT_RE = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}\z/i.freeze
|
|
30
33
|
|
|
34
|
+
# Keys whose value never leaves the process. Compared against the key with case
|
|
35
|
+
# and separators removed, so `api_key`, `apiKey` and `API-KEY` all match
|
|
36
|
+
# `apikey`, and the test is a substring one, so `stripe_secret_key` matches too.
|
|
37
|
+
SCRUB_KEYS = %w[
|
|
38
|
+
password passwd passphrase secret token apikey authorization credential
|
|
39
|
+
cookie session privatekey accesskey cardnumber creditcard cvv ssn
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
REDACTED = "[redacted]"
|
|
43
|
+
TRUNCATED = "[truncated]"
|
|
44
|
+
CIRCULAR = "[circular]"
|
|
45
|
+
# Deep enough for real context objects, shallow enough to stay cheap.
|
|
46
|
+
MAX_SCRUB_DEPTH = 8
|
|
47
|
+
|
|
48
|
+
BEARER_RE = %r{\bbearer\s+[\w.~+/-]+=*}i.freeze
|
|
49
|
+
JWT_RE = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/.freeze
|
|
50
|
+
DIGIT_RUN_RE = /\b(?:\d[ -]?){12,18}\d\b/.freeze
|
|
51
|
+
EMAIL_RE = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)+/.freeze
|
|
52
|
+
|
|
31
53
|
class << self
|
|
32
54
|
# Configure the reporter. Call once at startup before mounting the middleware.
|
|
33
55
|
def init(url:, token: nil, environment:, release: nil)
|
|
@@ -132,6 +154,33 @@ module Octri
|
|
|
132
154
|
patch_net_http if http
|
|
133
155
|
end
|
|
134
156
|
|
|
157
|
+
# ── Scrubbing ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
# Redact more key names, on top of the built-in list. Matching ignores case
|
|
160
|
+
# and separators and is a substring test, so `account` also covers
|
|
161
|
+
# `account_number`.
|
|
162
|
+
#
|
|
163
|
+
# Octri.add_scrub_fields("account_number", "otp")
|
|
164
|
+
def add_scrub_fields(*fields)
|
|
165
|
+
@extra_scrub_keys ||= []
|
|
166
|
+
fields.flatten.each do |field|
|
|
167
|
+
key = normalize_key(field)
|
|
168
|
+
@extra_scrub_keys << key unless key.empty? || @extra_scrub_keys.include?(key)
|
|
169
|
+
end
|
|
170
|
+
@extra_scrub_keys
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Run a block on every payload just before it is sent. Return the payload
|
|
174
|
+
# (editing it in place is fine) to send it, or nil to drop the event:
|
|
175
|
+
#
|
|
176
|
+
# Octri.set_before_send { |payload| payload[:path] == "/health" ? nil : payload }
|
|
177
|
+
#
|
|
178
|
+
# Redaction still runs afterwards, so a hook cannot leak a credential by
|
|
179
|
+
# accident. Call it without a block to remove the hook.
|
|
180
|
+
def set_before_send(&hook)
|
|
181
|
+
@before_send = hook
|
|
182
|
+
end
|
|
183
|
+
|
|
135
184
|
# ── Reporting ──────────────────────────────────────────────────────────────
|
|
136
185
|
# Log an application event directly, without a generated Octri API SDK.
|
|
137
186
|
# Delivery is fire-and-forget; event_id may be supplied for idempotency.
|
|
@@ -224,7 +273,7 @@ module Octri
|
|
|
224
273
|
Time.now.utc.iso8601(3)
|
|
225
274
|
end
|
|
226
275
|
|
|
227
|
-
# True when host:port is the monitoring backend
|
|
276
|
+
# True when host:port is the monitoring backend, used to avoid tracing our
|
|
228
277
|
# own reporting requests (which would recurse).
|
|
229
278
|
def monitoring_endpoint?(host, port)
|
|
230
279
|
return false if @config.nil? || @config.url.nil?
|
|
@@ -292,7 +341,8 @@ module Octri
|
|
|
292
341
|
path = loc.absolute_path || loc.path
|
|
293
342
|
lineno = loc.lineno
|
|
294
343
|
frame = { function: loc.label, filename: path, lineno: lineno, colno: 0, inApp: in_app?(path) }
|
|
295
|
-
|
|
344
|
+
# Only your own files: the dashboard shows them, gem source is noise.
|
|
345
|
+
lines = frame[:inApp] ? read_source(path) : nil
|
|
296
346
|
if lines && lineno >= 1 && lineno <= lines.length
|
|
297
347
|
idx = lineno - 1
|
|
298
348
|
frame[:contextLine] = lines[idx]
|
|
@@ -319,23 +369,114 @@ module Octri
|
|
|
319
369
|
return @source_cache[path] if @source_cache.key?(path)
|
|
320
370
|
|
|
321
371
|
lines = begin
|
|
322
|
-
|
|
372
|
+
# Big files are skipped, not truncated, so line numbers keep lining up.
|
|
373
|
+
File.size(path) <= MAX_SOURCE_BYTES ? File.readlines(path, chomp: true) : nil
|
|
323
374
|
rescue StandardError
|
|
324
375
|
nil
|
|
325
376
|
end
|
|
377
|
+
# A stack can name any number of files, so the cache is bounded too.
|
|
378
|
+
@source_cache.shift if @source_cache.size >= MAX_CACHED_SOURCES
|
|
326
379
|
@source_cache[path] = lines
|
|
327
380
|
lines
|
|
328
381
|
end
|
|
329
382
|
|
|
383
|
+
def normalize_key(key)
|
|
384
|
+
key.to_s.downcase.gsub(/[^a-z0-9]/, "")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def secret_key?(key)
|
|
388
|
+
normalized = normalize_key(key)
|
|
389
|
+
return false if normalized.empty?
|
|
390
|
+
|
|
391
|
+
SCRUB_KEYS.any? { |candidate| normalized.include?(candidate) } ||
|
|
392
|
+
(@extra_scrub_keys || []).any? { |candidate| normalized.include?(candidate) }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Tells a card number from the order ids and timestamps that look like one.
|
|
396
|
+
def passes_luhn?(digits)
|
|
397
|
+
sum = 0
|
|
398
|
+
double = false
|
|
399
|
+
digits.reverse.each_char do |char|
|
|
400
|
+
digit = char.ord - 48
|
|
401
|
+
if double
|
|
402
|
+
digit *= 2
|
|
403
|
+
digit -= 9 if digit > 9
|
|
404
|
+
end
|
|
405
|
+
sum += digit
|
|
406
|
+
double = !double
|
|
407
|
+
end
|
|
408
|
+
(sum % 10).zero?
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Removes credentials and personal data that leaked into free text.
|
|
412
|
+
def scrub_text(value)
|
|
413
|
+
return value if value.empty?
|
|
414
|
+
|
|
415
|
+
value
|
|
416
|
+
.gsub(BEARER_RE, REDACTED)
|
|
417
|
+
.gsub(JWT_RE, REDACTED)
|
|
418
|
+
.gsub(DIGIT_RUN_RE) { |run| passes_luhn?(run.delete("^0-9")) ? REDACTED : run }
|
|
419
|
+
.gsub(EMAIL_RE, REDACTED)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Redacts credential-shaped keys anywhere in the payload, and strips secrets
|
|
423
|
+
# out of the free text around them. `user` is the field you deliberately fill
|
|
424
|
+
# with an identity, so its strings are left alone; its keys are still checked.
|
|
425
|
+
def scrub_value(value, depth, text, seen)
|
|
426
|
+
case value
|
|
427
|
+
when String
|
|
428
|
+
text ? scrub_text(value) : value
|
|
429
|
+
when Hash, Array
|
|
430
|
+
return TRUNCATED if depth >= MAX_SCRUB_DEPTH
|
|
431
|
+
# Walking a copy means a cycle would recurse forever, and a context hash
|
|
432
|
+
# holding a reference back to itself is worth surviving.
|
|
433
|
+
return CIRCULAR if seen.include?(value.object_id)
|
|
434
|
+
|
|
435
|
+
seen.add(value.object_id)
|
|
436
|
+
begin
|
|
437
|
+
scrub_collection(value, depth, text, seen)
|
|
438
|
+
ensure
|
|
439
|
+
seen.delete(value.object_id)
|
|
440
|
+
end
|
|
441
|
+
else
|
|
442
|
+
value
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def scrub_collection(value, depth, text, seen)
|
|
447
|
+
return value.map { |item| scrub_value(item, depth + 1, text, seen) } if value.is_a?(Array)
|
|
448
|
+
|
|
449
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
450
|
+
out[key] = if secret_key?(key)
|
|
451
|
+
REDACTED
|
|
452
|
+
else
|
|
453
|
+
scrub_value(nested, depth + 1, text && key.to_s != "user", seen)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# The last thing every payload passes through. Both the hook and the
|
|
459
|
+
# redaction live here rather than in the capture methods, so nothing can be
|
|
460
|
+
# reported around them.
|
|
461
|
+
def scrub_payload(payload)
|
|
462
|
+
hooked = @before_send ? @before_send.call(payload) : payload
|
|
463
|
+
return nil unless hooked.is_a?(Hash)
|
|
464
|
+
|
|
465
|
+
scrub_value(hooked, 0, true, Set.new)
|
|
466
|
+
end
|
|
467
|
+
|
|
330
468
|
def post_json(path, payload, idempotency_key:)
|
|
331
469
|
cfg = @config
|
|
332
470
|
return if cfg.nil?
|
|
333
471
|
return unless safe_idempotency_key?(idempotency_key)
|
|
334
472
|
return if cfg.token && cfg.token != "" && !safe_header_value?(cfg.token)
|
|
335
473
|
|
|
474
|
+
scrubbed = scrub_payload(payload)
|
|
475
|
+
return if scrubbed.nil?
|
|
476
|
+
|
|
336
477
|
begin
|
|
337
478
|
Thread.new do
|
|
338
|
-
body = JSON.generate(
|
|
479
|
+
body = JSON.generate(scrubbed)
|
|
339
480
|
uri = URI("#{cfg.url}#{path}")
|
|
340
481
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
341
482
|
http.use_ssl = uri.scheme == "https"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: octri
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Octri
|
|
@@ -16,6 +16,7 @@ executables: []
|
|
|
16
16
|
extensions: []
|
|
17
17
|
extra_rdoc_files: []
|
|
18
18
|
files:
|
|
19
|
+
- CHANGELOG.md
|
|
19
20
|
- LICENSE
|
|
20
21
|
- README.md
|
|
21
22
|
- lib/octri.rb
|