octri 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/lib/octri/rack.rb +43 -0
- data/lib/octri.rb +413 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b7710dbc38e8c6678e6e5a38456e0dfdf26ce8098b580e5cad6a00ed7ef210e2
|
|
4
|
+
data.tar.gz: b3496fdae687f5e386361fa122bf61d80af342a2d22a6cc239cccbc2c74fcb9c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cd2be05e16cd704c0e1118dbf477b632e764c3cc5f770f3ce6fbfb821b72d67a97ecf912b2d12ab1e15239fa247ed15947d61e830c1da0d5c7d32abd23f66893
|
|
7
|
+
data.tar.gz: 6d0783dd74030d6657e45b7c507add769195eeab6d07bf9024e4b2c79e3f4ab9dbc5b58d1228d2cfc38bc2a16790f3780a8b8579729ae46713704f44dcef3556
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Octri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# octri (Ruby)
|
|
2
|
+
|
|
3
|
+
**Error and performance monitoring for Ruby backends.** Report errors out of Rack
|
|
4
|
+
or Rails with original-source context per stack frame, time every request and any
|
|
5
|
+
sub-span you open into a waterfall, and join each server error to the client SDK
|
|
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.
|
|
8
|
+
|
|
9
|
+
Octri turns an OpenAPI spec into a documentation site, client SDKs for ten
|
|
10
|
+
languages, an MCP server your AI assistant can call, and monitoring for the
|
|
11
|
+
API behind them. This gem is the Ruby monitoring runtime, and it works on its
|
|
12
|
+
own: a generated Octri API SDK is not required. See
|
|
13
|
+
[octri.dev/monitoring](https://octri.dev/monitoring).
|
|
14
|
+
|
|
15
|
+
Ruby 2.7 or newer. Standard library only. The Ruby sibling of
|
|
16
|
+
[`@octri/node`](https://github.com/octridev/octri-node).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
gem install octri
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Setup (Rack / Rails)
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "octri"
|
|
28
|
+
require "octri/rack"
|
|
29
|
+
|
|
30
|
+
Octri.init(
|
|
31
|
+
url: "https://monitoring.example.com",
|
|
32
|
+
token: ENV["OCTRI_TOKEN"],
|
|
33
|
+
environment: "<your project id>",
|
|
34
|
+
release: ENV["GIT_SHA"] # optional
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# config.ru / Rails middleware stack:
|
|
38
|
+
use Octri::Rack
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Hosted users can copy the project-scoped URL, token, and environment from the
|
|
42
|
+
Monitoring connection settings (or its API). Omit `token:` only when pointing
|
|
43
|
+
at an open self-hosted ingest endpoint. Every request carries an idempotency key.
|
|
44
|
+
|
|
45
|
+
## Standalone events
|
|
46
|
+
|
|
47
|
+
No generated API SDK is required to send your own events:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Octri.capture_event(
|
|
51
|
+
"checkout.completed",
|
|
52
|
+
user: { id: customer.id },
|
|
53
|
+
tags: { region: "eu-west", plan: "growth" },
|
|
54
|
+
context: { order_id: order.id, total: order.total }
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Delivery is best-effort and runs on a background thread. Pass `event_id:` to
|
|
59
|
+
make a retried delivery idempotent.
|
|
60
|
+
|
|
61
|
+
`Octri::Rack` captures any exception raised by the app (linked to the request's
|
|
62
|
+
trace) and re-raises, and times the request as a server span.
|
|
63
|
+
|
|
64
|
+
## Automatic instrumentation
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Octri.auto_instrument # traces outbound Net::HTTP calls
|
|
68
|
+
Octri.instrument(PG::Connection, [:exec], op: "db") # your DB client / util class, once
|
|
69
|
+
Octri.instrument(cache, [:get, :set], op: "cache")
|
|
70
|
+
```
|
|
71
|
+
|
|
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
|
|
74
|
+
are never traced (no feedback loop).
|
|
75
|
+
|
|
76
|
+
## Sub-spans (where time goes)
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Octri.span("orders.list", op: "db") do
|
|
80
|
+
Order.where(status: "open").to_a
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# or manual control:
|
|
84
|
+
s = Octri.start_span("cache.get", op: "cache")
|
|
85
|
+
value = cache.read(key)
|
|
86
|
+
s.finish
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`op` ("db", "cache", "http", …) color-codes the waterfall; nested `Octri.span`
|
|
90
|
+
calls nest correctly.
|
|
91
|
+
|
|
92
|
+
## Manual error capture
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
begin
|
|
96
|
+
risky!
|
|
97
|
+
rescue => e
|
|
98
|
+
Octri.capture_error(e)
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## The rest of Octri
|
|
106
|
+
|
|
107
|
+
| Product | What it does |
|
|
108
|
+
|---|---|
|
|
109
|
+
| [API Studio](https://octri.dev/api-studio) | Your OpenAPI spec becomes a hosted documentation site with a live request playground, editable page by page. |
|
|
110
|
+
| [SDK Studio](https://octri.dev/sdk-studio) | The same spec becomes client libraries for ten languages, versioned and released together. |
|
|
111
|
+
| [MCP](https://octri.dev/mcp) | Your endpoints and docs become tools an AI assistant can call, generated from the same spec. |
|
|
112
|
+
| [Monitoring](https://octri.dev/monitoring) | Errors, traces, uptime and releases for the API, joined to the SDK calls that reached it. |
|
|
113
|
+
|
|
114
|
+
### Monitoring runtimes
|
|
115
|
+
|
|
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)
|
|
130
|
+
|
|
131
|
+
MIT licensed.
|
data/lib/octri/rack.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octri"
|
|
4
|
+
|
|
5
|
+
module Octri
|
|
6
|
+
# Rack middleware: times each request as a server span (a child of the client
|
|
7
|
+
# SDK span via the incoming `traceparent`) and reports any raised exception as
|
|
8
|
+
# an error linked to the same trace, then re-raises. Sub-spans opened with
|
|
9
|
+
# Octri.span / Octri.start_span during the request nest under it.
|
|
10
|
+
#
|
|
11
|
+
# use Octri::Rack
|
|
12
|
+
class Rack
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
trace = Octri.trace_from_header(env["HTTP_TRACEPARENT"])
|
|
19
|
+
span_id = Octri.new_span_id
|
|
20
|
+
start = Octri.now_iso
|
|
21
|
+
prev = Octri.set_current_span(trace.trace_id, span_id)
|
|
22
|
+
method = env["REQUEST_METHOD"]
|
|
23
|
+
path = env["PATH_INFO"]
|
|
24
|
+
status = 500
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
result = @app.call(env)
|
|
28
|
+
status = result[0]
|
|
29
|
+
result
|
|
30
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
31
|
+
Octri.capture_error(e, trace: trace, method: method, path: path, status_code: 500)
|
|
32
|
+
raise
|
|
33
|
+
ensure
|
|
34
|
+
Octri.reset_current_span(prev)
|
|
35
|
+
Octri.capture_span(
|
|
36
|
+
trace_id: trace.trace_id, span_id: span_id, parent_span_id: trace.parent_span_id,
|
|
37
|
+
name: "#{method} #{path}", service: "server", start_time: start,
|
|
38
|
+
end_time: Octri.now_iso, status: status.to_i >= 500 ? "error" : "ok"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/octri.rb
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Octri — server-side error monitoring for Ruby backends.
|
|
4
|
+
#
|
|
5
|
+
# Add it to your live API; it reports backend errors to your Octri monitoring
|
|
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 —
|
|
8
|
+
# so the dashboard shows the full client -> server stack under one trace. It also
|
|
9
|
+
# times requests (and any sub-spans you open) into the request waterfall.
|
|
10
|
+
#
|
|
11
|
+
# require "octri"
|
|
12
|
+
# require "octri/rack"
|
|
13
|
+
# Octri.init(url: "https://monitoring.example.com", token: ENV["OCTRI_TOKEN"], environment: "<project id>")
|
|
14
|
+
# use Octri::Rack
|
|
15
|
+
|
|
16
|
+
require "securerandom"
|
|
17
|
+
require "net/http"
|
|
18
|
+
require "uri"
|
|
19
|
+
require "json"
|
|
20
|
+
require "time"
|
|
21
|
+
|
|
22
|
+
module Octri
|
|
23
|
+
Config = Struct.new(:url, :token, :environment, :release)
|
|
24
|
+
Trace = Struct.new(:trace_id, :parent_span_id)
|
|
25
|
+
|
|
26
|
+
CONTEXT_LINES = 5
|
|
27
|
+
REQUEST_TIMEOUT_SECONDS = 5
|
|
28
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 256
|
|
29
|
+
TRACEPARENT_RE = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}\z/i.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Configure the reporter. Call once at startup before mounting the middleware.
|
|
33
|
+
def init(url:, token: nil, environment:, release: nil)
|
|
34
|
+
@config = Config.new(url.sub(%r{/+\z}, ""), token, environment, release)
|
|
35
|
+
@source_cache = {}
|
|
36
|
+
@config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :config
|
|
40
|
+
|
|
41
|
+
def new_span_id
|
|
42
|
+
SecureRandom.hex(8)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── Trace context (W3C) ──────────────────────────────────────────────────
|
|
46
|
+
def trace_from_header(traceparent)
|
|
47
|
+
if traceparent && (m = traceparent.to_s.strip.match(TRACEPARENT_RE))
|
|
48
|
+
unless all_zeros?(m[1]) || all_zeros?(m[2])
|
|
49
|
+
return Trace.new(m[1].downcase, m[2].downcase)
|
|
50
|
+
end
|
|
51
|
+
Trace.new(SecureRandom.hex(16), nil)
|
|
52
|
+
else
|
|
53
|
+
Trace.new(SecureRandom.hex(16), nil)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Sub-span context (thread-local) ───────────────────────────────────────
|
|
58
|
+
def current_span
|
|
59
|
+
Thread.current[:octri_span]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_current_span(trace_id, span_id)
|
|
63
|
+
prev = Thread.current[:octri_span]
|
|
64
|
+
Thread.current[:octri_span] = { trace_id: trace_id, span_id: span_id }
|
|
65
|
+
prev
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset_current_span(prev)
|
|
69
|
+
Thread.current[:octri_span] = prev
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Open a sub-span under the active request span; call #finish when done.
|
|
73
|
+
# Returns a no-op handle outside a request or before init.
|
|
74
|
+
def start_span(name, op: nil)
|
|
75
|
+
ctx = current_span
|
|
76
|
+
return NoopSpan.new if @config.nil? || ctx.nil?
|
|
77
|
+
|
|
78
|
+
ActiveSpan.new(ctx[:trace_id], new_span_id, ctx[:span_id], name, op || "server")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Time a block as a sub-span under the active request span (nests correctly).
|
|
82
|
+
#
|
|
83
|
+
# Octri.span("orders.list", op: "db") { db.query(sql) }
|
|
84
|
+
def span(name, op: nil)
|
|
85
|
+
ctx = current_span
|
|
86
|
+
return yield if @config.nil? || ctx.nil?
|
|
87
|
+
|
|
88
|
+
span_id = new_span_id
|
|
89
|
+
start = now_iso
|
|
90
|
+
prev = set_current_span(ctx[:trace_id], span_id)
|
|
91
|
+
status = "ok"
|
|
92
|
+
begin
|
|
93
|
+
yield
|
|
94
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
95
|
+
status = "error"
|
|
96
|
+
raise
|
|
97
|
+
ensure
|
|
98
|
+
reset_current_span(prev)
|
|
99
|
+
capture_span(
|
|
100
|
+
trace_id: ctx[:trace_id], span_id: span_id, parent_span_id: ctx[:span_id],
|
|
101
|
+
name: name, service: op || "server", start_time: start, end_time: now_iso, status: status
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── Automatic instrumentation ─────────────────────────────────────────────
|
|
107
|
+
# Wrap the given methods so every call becomes a sub-span. Point it at a DB
|
|
108
|
+
# client, cache, or util class once and all calls are traced without per-call
|
|
109
|
+
# code. `target` may be a Class/Module (wraps instance methods) or an object
|
|
110
|
+
# (wraps its singleton methods).
|
|
111
|
+
#
|
|
112
|
+
# Octri.instrument(PG::Connection, [:exec, :exec_params], op: "db")
|
|
113
|
+
# Octri.instrument(cache, [:get, :set], op: "cache")
|
|
114
|
+
def instrument(target, methods, op: nil, name: nil)
|
|
115
|
+
mod = Module.new
|
|
116
|
+
owner = target.is_a?(Module) ? target : target.singleton_class
|
|
117
|
+
methods.each do |method_name|
|
|
118
|
+
next unless owner.method_defined?(method_name) || owner.private_method_defined?(method_name)
|
|
119
|
+
|
|
120
|
+
mod.define_method(method_name) do |*args, **kwargs, &blk|
|
|
121
|
+
span_name = name ? name.call(method_name, args) : method_name.to_s
|
|
122
|
+
Octri.span(span_name, op: op) { super(*args, **kwargs, &blk) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
owner.prepend(mod)
|
|
126
|
+
target
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Turn on zero-config tracing for outbound HTTP (Net::HTTP). Calls to your
|
|
130
|
+
# monitoring backend are never traced (no feedback loop).
|
|
131
|
+
def auto_instrument(http: true)
|
|
132
|
+
patch_net_http if http
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ── Reporting ──────────────────────────────────────────────────────────────
|
|
136
|
+
# Log an application event directly, without a generated Octri API SDK.
|
|
137
|
+
# Delivery is fire-and-forget; event_id may be supplied for idempotency.
|
|
138
|
+
def capture_event(message, level: "info", timestamp: nil, operation_id: nil,
|
|
139
|
+
method: nil, path: nil, status_code: nil, latency_ms: nil,
|
|
140
|
+
attempt: nil, request_id: nil, user: nil, tags: nil,
|
|
141
|
+
context: nil, breadcrumbs: nil, fingerprint: nil, trace: nil,
|
|
142
|
+
span_id: nil, event_id: nil)
|
|
143
|
+
return if @config.nil?
|
|
144
|
+
|
|
145
|
+
resolved_event_id = resolve_event_id(event_id)
|
|
146
|
+
payload = {
|
|
147
|
+
eventId: resolved_event_id,
|
|
148
|
+
timestamp: timestamp || now_iso,
|
|
149
|
+
level: level,
|
|
150
|
+
message: message,
|
|
151
|
+
environment: @config.environment,
|
|
152
|
+
tags: { "octri.origin" => "standalone" }.merge(tags || {})
|
|
153
|
+
}
|
|
154
|
+
payload[:release] = @config.release if @config.release
|
|
155
|
+
payload[:operationId] = operation_id if operation_id
|
|
156
|
+
payload[:method] = method if method
|
|
157
|
+
payload[:path] = path if path
|
|
158
|
+
payload[:statusCode] = status_code unless status_code.nil?
|
|
159
|
+
payload[:latencyMs] = latency_ms unless latency_ms.nil?
|
|
160
|
+
payload[:attempt] = attempt unless attempt.nil?
|
|
161
|
+
payload[:requestId] = request_id if request_id
|
|
162
|
+
payload[:user] = user if user
|
|
163
|
+
payload[:context] = context if context
|
|
164
|
+
payload[:breadcrumbs] = breadcrumbs if breadcrumbs
|
|
165
|
+
payload[:fingerprint] = fingerprint if fingerprint
|
|
166
|
+
payload[:traceId] = trace.trace_id if trace
|
|
167
|
+
payload[:spanId] = span_id if span_id
|
|
168
|
+
post_json("/ingest", payload, idempotency_key: resolved_event_id)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
# Invalid caller data must never affect the host application.
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def capture_error(exception, trace: nil, method: nil, path: nil, status_code: nil, level: "error")
|
|
175
|
+
return if @config.nil?
|
|
176
|
+
|
|
177
|
+
tr = trace || trace_from_current
|
|
178
|
+
event_id = SecureRandom.hex(16)
|
|
179
|
+
payload = {
|
|
180
|
+
eventId: event_id,
|
|
181
|
+
timestamp: now_iso,
|
|
182
|
+
level: level,
|
|
183
|
+
environment: @config.environment,
|
|
184
|
+
traceId: tr.trace_id,
|
|
185
|
+
spanId: new_span_id,
|
|
186
|
+
tags: { "octri.origin" => "server" },
|
|
187
|
+
error: {
|
|
188
|
+
name: exception.class.name,
|
|
189
|
+
message: exception.message.to_s,
|
|
190
|
+
frames: build_frames(exception)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
payload[:release] = @config.release if @config.release
|
|
194
|
+
payload[:method] = method if method
|
|
195
|
+
payload[:path] = path if path
|
|
196
|
+
payload[:statusCode] = status_code if status_code
|
|
197
|
+
post_json("/ingest", payload, idempotency_key: event_id)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
# Invalid exception-like objects must not affect the host application.
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def capture_span(trace_id:, span_id:, name:, start_time:, parent_span_id: nil,
|
|
204
|
+
service: "server", operation_id: nil, end_time: nil, status: "ok")
|
|
205
|
+
return if @config.nil?
|
|
206
|
+
return unless valid_required_span_value?(trace_id) && valid_required_span_value?(span_id) &&
|
|
207
|
+
valid_required_span_value?(name) && valid_iso_time?(start_time)
|
|
208
|
+
return if end_time && !valid_iso_time?(end_time)
|
|
209
|
+
return if (trace_id.length == 32 && all_zeros?(trace_id)) ||
|
|
210
|
+
(span_id.length == 16 && all_zeros?(span_id))
|
|
211
|
+
|
|
212
|
+
payload = { traceId: trace_id, spanId: span_id, environment: @config.environment,
|
|
213
|
+
name: name, service: service, startTime: start_time, status: status }
|
|
214
|
+
payload[:parentSpanId] = parent_span_id if parent_span_id
|
|
215
|
+
payload[:endTime] = end_time if end_time
|
|
216
|
+
payload[:operationId] = operation_id if operation_id
|
|
217
|
+
post_json("/traces", payload, idempotency_key: "#{trace_id}:#{span_id}")
|
|
218
|
+
rescue StandardError
|
|
219
|
+
# Invalid caller data must never affect the host application.
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def now_iso
|
|
224
|
+
Time.now.utc.iso8601(3)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# True when host:port is the monitoring backend — used to avoid tracing our
|
|
228
|
+
# own reporting requests (which would recurse).
|
|
229
|
+
def monitoring_endpoint?(host, port)
|
|
230
|
+
return false if @config.nil? || @config.url.nil?
|
|
231
|
+
|
|
232
|
+
uri = URI(@config.url)
|
|
233
|
+
uri.host == host && uri.port == port
|
|
234
|
+
rescue StandardError
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def all_zeros?(value)
|
|
241
|
+
value.is_a?(String) && !value.empty? && value.each_char.all? { |char| char == "0" }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def safe_header_value?(value)
|
|
245
|
+
value.is_a?(String) && !value.empty? && !value.include?("\r") && !value.include?("\n")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def safe_idempotency_key?(value)
|
|
249
|
+
safe_header_value?(value) && value.bytesize <= MAX_IDEMPOTENCY_KEY_LENGTH
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def resolve_event_id(value)
|
|
253
|
+
candidate = value.is_a?(String) ? value.strip : ""
|
|
254
|
+
safe_idempotency_key?(candidate) ? candidate : SecureRandom.hex(16)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def valid_required_span_value?(value)
|
|
258
|
+
value.is_a?(String) && !value.strip.empty?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def valid_iso_time?(value)
|
|
262
|
+
return false unless valid_required_span_value?(value)
|
|
263
|
+
|
|
264
|
+
Time.iso8601(value)
|
|
265
|
+
true
|
|
266
|
+
rescue ArgumentError
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def patch_net_http
|
|
271
|
+
require "net/http"
|
|
272
|
+
return if Net::HTTP.instance_variable_get(:@octri_patched)
|
|
273
|
+
|
|
274
|
+
Net::HTTP.prepend(NetHTTPPatch)
|
|
275
|
+
Net::HTTP.instance_variable_set(:@octri_patched, true)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def trace_from_current
|
|
279
|
+
ctx = current_span
|
|
280
|
+
return Trace.new(ctx[:trace_id], ctx[:span_id]) if ctx
|
|
281
|
+
|
|
282
|
+
Trace.new(SecureRandom.hex(16), nil)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# An exception's backtrace is innermost-first, matching the dashboard's
|
|
286
|
+
# culprit = top frame.
|
|
287
|
+
def build_frames(exception)
|
|
288
|
+
locations = exception.backtrace_locations
|
|
289
|
+
return [] if locations.nil?
|
|
290
|
+
|
|
291
|
+
locations.map do |loc|
|
|
292
|
+
path = loc.absolute_path || loc.path
|
|
293
|
+
lineno = loc.lineno
|
|
294
|
+
frame = { function: loc.label, filename: path, lineno: lineno, colno: 0, inApp: in_app?(path) }
|
|
295
|
+
lines = read_source(path)
|
|
296
|
+
if lines && lineno >= 1 && lineno <= lines.length
|
|
297
|
+
idx = lineno - 1
|
|
298
|
+
frame[:contextLine] = lines[idx]
|
|
299
|
+
pre = lines[[0, idx - CONTEXT_LINES].max...idx]
|
|
300
|
+
post = lines[(idx + 1)...(idx + 1 + CONTEXT_LINES)]
|
|
301
|
+
frame[:preContext] = pre if pre && !pre.empty?
|
|
302
|
+
frame[:postContext] = post if post && !post.empty?
|
|
303
|
+
end
|
|
304
|
+
frame
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def in_app?(path)
|
|
309
|
+
return false if path.nil?
|
|
310
|
+
|
|
311
|
+
!path.include?("/gems/") && !path.include?("/ruby/") &&
|
|
312
|
+
!path.start_with?(RbConfig::CONFIG["libdir"].to_s)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def read_source(path)
|
|
316
|
+
return nil if path.nil?
|
|
317
|
+
|
|
318
|
+
@source_cache ||= {}
|
|
319
|
+
return @source_cache[path] if @source_cache.key?(path)
|
|
320
|
+
|
|
321
|
+
lines = begin
|
|
322
|
+
File.readlines(path, chomp: true)
|
|
323
|
+
rescue StandardError
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
@source_cache[path] = lines
|
|
327
|
+
lines
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def post_json(path, payload, idempotency_key:)
|
|
331
|
+
cfg = @config
|
|
332
|
+
return if cfg.nil?
|
|
333
|
+
return unless safe_idempotency_key?(idempotency_key)
|
|
334
|
+
return if cfg.token && cfg.token != "" && !safe_header_value?(cfg.token)
|
|
335
|
+
|
|
336
|
+
begin
|
|
337
|
+
Thread.new do
|
|
338
|
+
body = JSON.generate(payload)
|
|
339
|
+
uri = URI("#{cfg.url}#{path}")
|
|
340
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
341
|
+
http.use_ssl = uri.scheme == "https"
|
|
342
|
+
http.open_timeout = REQUEST_TIMEOUT_SECONDS
|
|
343
|
+
http.read_timeout = REQUEST_TIMEOUT_SECONDS
|
|
344
|
+
http.write_timeout = REQUEST_TIMEOUT_SECONDS if http.respond_to?(:write_timeout=)
|
|
345
|
+
req = Net::HTTP::Post.new(uri)
|
|
346
|
+
req["content-type"] = "application/json"
|
|
347
|
+
req["idempotency-key"] = idempotency_key
|
|
348
|
+
req["authorization"] = "Bearer #{cfg.token}" if cfg.token && cfg.token != ""
|
|
349
|
+
req.body = body
|
|
350
|
+
http.request(req)
|
|
351
|
+
rescue StandardError
|
|
352
|
+
# A reporting failure must never affect the app.
|
|
353
|
+
end
|
|
354
|
+
rescue StandardError
|
|
355
|
+
# Thread exhaustion must not make monitoring affect the application.
|
|
356
|
+
nil
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Prepended to Net::HTTP by auto_instrument: wraps outbound requests in an
|
|
362
|
+
# `http` span (skipping requests to the monitoring backend, and when there's no
|
|
363
|
+
# active request span).
|
|
364
|
+
module NetHTTPPatch
|
|
365
|
+
def request(req, body = nil, &block)
|
|
366
|
+
cfg = Octri.config
|
|
367
|
+
if cfg.nil? || Octri.current_span.nil? || Octri.monitoring_endpoint?(address, port)
|
|
368
|
+
return super
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
path = req.respond_to?(:path) ? req.path : ""
|
|
372
|
+
method = req.respond_to?(:method) ? req.method : "GET"
|
|
373
|
+
Octri.span("#{method} #{address}:#{port}#{path}", op: "http") { super }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# A sub-span handle for manual control; #finish reports it (named `finish`
|
|
378
|
+
# rather than `end`, which is a Ruby keyword).
|
|
379
|
+
class ActiveSpan
|
|
380
|
+
def initialize(trace_id, span_id, parent_span_id, name, service)
|
|
381
|
+
@trace_id = trace_id
|
|
382
|
+
@span_id = span_id
|
|
383
|
+
@parent_span_id = parent_span_id
|
|
384
|
+
@name = name
|
|
385
|
+
@service = service
|
|
386
|
+
@start = Octri.now_iso
|
|
387
|
+
@status = "ok"
|
|
388
|
+
@ended = false
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def fail
|
|
392
|
+
@status = "error"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def finish(status = nil)
|
|
396
|
+
return if @ended
|
|
397
|
+
|
|
398
|
+
@ended = true
|
|
399
|
+
Octri.capture_span(
|
|
400
|
+
trace_id: @trace_id, span_id: @span_id, parent_span_id: @parent_span_id,
|
|
401
|
+
name: @name, service: @service, start_time: @start,
|
|
402
|
+
end_time: Octri.now_iso, status: status || @status
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# No-op handle returned outside a request.
|
|
408
|
+
class NoopSpan
|
|
409
|
+
def fail; end
|
|
410
|
+
|
|
411
|
+
def finish(_status = nil); end
|
|
412
|
+
end
|
|
413
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: octri
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Octri
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Reports backend errors to your Octri monitoring project and links them
|
|
13
|
+
to client SDK errors via W3C trace context; times requests and sub-spans into the
|
|
14
|
+
request waterfall.
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/octri.rb
|
|
22
|
+
- lib/octri/rack.rb
|
|
23
|
+
homepage: https://octri.dev
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
homepage_uri: https://octri.dev
|
|
28
|
+
documentation_uri: https://docs.octri.dev/docs
|
|
29
|
+
bug_tracker_uri: https://octri.dev/support
|
|
30
|
+
source_code_uri: https://github.com/octridev/octri-ruby
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.7'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 3.6.9
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: Server-side error monitoring for Ruby backends (Rack / Rails).
|
|
48
|
+
test_files: []
|