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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +52 -17
  4. data/lib/octri.rb +147 -6
  5. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7710dbc38e8c6678e6e5a38456e0dfdf26ce8098b580e5cad6a00ed7ef210e2
4
- data.tar.gz: b3496fdae687f5e386361fa122bf61d80af342a2d22a6cc239cccbc2c74fcb9c
3
+ metadata.gz: 15e020993bd573362db0191b73266111e568d1e12d0977b2ce1b82c85c0bd04d
4
+ data.tar.gz: a51dd16d8ff052a321cd2dbcd9701ca0a3f6ea5e73874385859c3a90720bf580
5
5
  SHA512:
6
- metadata.gz: cd2be05e16cd704c0e1118dbf477b632e764c3cc5f770f3ce6fbfb821b72d67a97ecf912b2d12ab1e15239fa247ed15947d61e830c1da0d5c7d32abd23f66893
7
- data.tar.gz: 6d0783dd74030d6657e45b7c507add769195eeab6d07bf9024e4b2c79e3f4ab9dbc5b58d1228d2cfc38bc2a16790f3780a8b8579729ae46713704f44dcef3556
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 → server stack under one trace.
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 — no per-call code. Calls to your monitoring backend
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", …) color-codes the waterfall; nested `Octri.span`
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
- [Documentation](https://docs.octri.dev/docs) ·
128
- [Pricing](https://octri.dev/pricing) ·
129
- [Changelog](https://docs.octri.dev/changelog)
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 — server-side error monitoring for Ruby backends.
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 — used to avoid tracing our
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
- lines = read_source(path)
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
- File.readlines(path, chomp: true)
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(payload)
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.0.0
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