visionary-sdk 0.4.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +202 -0
  4. data/README.md +198 -0
  5. data/lib/visionary/agent.rb +174 -0
  6. data/lib/visionary/body.rb +162 -0
  7. data/lib/visionary/config.rb +151 -0
  8. data/lib/visionary/context.rb +97 -0
  9. data/lib/visionary/fork.rb +16 -0
  10. data/lib/visionary/middleware.rb +169 -0
  11. data/lib/visionary/queue.rb +57 -0
  12. data/lib/visionary/railtie.rb +48 -0
  13. data/lib/visionary/redactor.rb +94 -0
  14. data/lib/visionary/retention.rb +90 -0
  15. data/lib/visionary/schema/mysql/requests.sql +37 -0
  16. data/lib/visionary/schema/mysql/system.sql +76 -0
  17. data/lib/visionary/schema/mysql/telemetry.sql +52 -0
  18. data/lib/visionary/schema/postgres/requests.sql +37 -0
  19. data/lib/visionary/schema/postgres/system.sql +76 -0
  20. data/lib/visionary/schema/postgres/telemetry.sql +52 -0
  21. data/lib/visionary/schema/sqlite/requests.sql +37 -0
  22. data/lib/visionary/schema/sqlite/system.sql +76 -0
  23. data/lib/visionary/schema/sqlite/telemetry.sql +52 -0
  24. data/lib/visionary/schema.rb +163 -0
  25. data/lib/visionary/schemas.rb +64 -0
  26. data/lib/visionary/store/active_record.rb +103 -0
  27. data/lib/visionary/store/d1.rb +129 -0
  28. data/lib/visionary/store/sql.rb +272 -0
  29. data/lib/visionary/store.rb +53 -0
  30. data/lib/visionary/system/recorder.rb +353 -0
  31. data/lib/visionary/system/sources.rb +191 -0
  32. data/lib/visionary/telemetry/errors.rb +118 -0
  33. data/lib/visionary/telemetry/hooks.rb +115 -0
  34. data/lib/visionary/telemetry/sql_text.rb +44 -0
  35. data/lib/visionary/telemetry.rb +116 -0
  36. data/lib/visionary/version.rb +5 -0
  37. data/lib/visionary/writer.rb +185 -0
  38. data/lib/visionary.rb +157 -0
  39. metadata +90 -0
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Visionary
4
+ # The raw capture of one request. Redaction, error-field extraction and
5
+ # user lookup happen on the writer thread, off the request path.
6
+ Entry = Struct.new(
7
+ :request_id, :started_at, :method, :path, :route, :query, :status, :duration_ms, :ip, :user_agent,
8
+ :user_id, :user_handle, :user_role, :headers, :request_content_type, :request_body, :request_truncated,
9
+ :request_size, :response_content_type, :response_body, :response_truncated, :response_size,
10
+ :totals, :span_rows, :error_rows,
11
+ keyword_init: true
12
+ )
13
+
14
+ # The single background thread that batches api_logs, spans, errors and
15
+ # releases rows. Queue items are [:request, Entry], [:span, row],
16
+ # [:error, row] or [:release, row].
17
+ class Writer
18
+ INSERTS = { request: "api_logs", span: "spans", error: "errors", release: "releases" }.freeze
19
+
20
+ def initialize(cfg, store, schemas, logger)
21
+ @cfg = cfg
22
+ @store = store
23
+ @schemas = schemas
24
+ @logger = logger
25
+ @redactor = Redactor.new(cfg.redact_headers, cfg.redact_fields, cfg.redact_request_fields)
26
+ @batch_size = cfg.batch_size.between?(1, 50) ? cfg.batch_size : 50
27
+ @flush_interval = cfg.flush_interval.to_f.positive? ? cfg.flush_interval.to_f : 3.0
28
+ @queue_size = cfg.queue_size.positive? ? cfg.queue_size : 5000
29
+ @sql = INSERTS.transform_values { |t| Schema.insert_sql(t, store.string_params?) }
30
+ @groups = []
31
+ @groups << :requests if cfg.log_requests
32
+ @groups << :telemetry if cfg.spans || cfg.errors || cfg.version
33
+ reset
34
+ end
35
+
36
+ def start
37
+ @thread = Thread.new { run }
38
+ @thread.name = "visionary-writer" if @thread.respond_to?(:name=)
39
+ self
40
+ end
41
+
42
+ # Threads don't survive fork; the child starts over with an empty queue.
43
+ def after_fork
44
+ reset
45
+ start
46
+ end
47
+
48
+ # Never blocks; a full queue drops and counts the item.
49
+ def enqueue(item)
50
+ @queue.push(item)
51
+ end
52
+
53
+ # Stops the thread after it has drained the queue, waiting at most
54
+ # timeout seconds. False if it was still busy then.
55
+ def close(timeout)
56
+ @queue.close
57
+ return true unless @thread
58
+ return true if @thread.join(timeout)
59
+ @logger.warn("visionary: log flush timed out; #{@queue.size} pending")
60
+ false
61
+ end
62
+
63
+ private
64
+
65
+ def reset
66
+ @queue = BoundedQueue.new(@queue_size)
67
+ @thread = nil
68
+ end
69
+
70
+ def run
71
+ Telemetry.internal!
72
+ @groups.each { |g| @schemas.ensure(g) }
73
+ next_flush = mono + @flush_interval
74
+ batch = []
75
+ loop do
76
+ items = @queue.take(@batch_size - batch.size, next_flush)
77
+ batch.concat(items)
78
+ closed = @queue.closed? && @queue.size.zero?
79
+ if batch.size >= @batch_size || mono >= next_flush || closed
80
+ write(batch) unless batch.empty?
81
+ batch = []
82
+ report_drops
83
+ next_flush = mono + @flush_interval
84
+ end
85
+ break if closed
86
+ end
87
+ rescue StandardError => e
88
+ @logger.error("visionary: writer stopped: #{e.class}: #{e.message}")
89
+ end
90
+
91
+ def report_drops
92
+ n = @queue.take_dropped
93
+ @logger.warn("visionary: queue full; #{n} entries dropped") if n.positive?
94
+ end
95
+
96
+ def write(batch)
97
+ stmts = begin
98
+ users = lookup_users(batch)
99
+ batch.flat_map { |kind, item| statements(kind, item, users) }
100
+ rescue StandardError => e
101
+ @logger.error("visionary: building rows failed; #{batch.size} entries dropped: #{e.class}: #{e.message}")
102
+ return
103
+ end
104
+ err = nil
105
+ 3.times do |attempt|
106
+ @groups.each { |g| @schemas.ensure(g) }
107
+ begin
108
+ @store.batch(stmts)
109
+ return
110
+ rescue StandardError => e
111
+ err = e
112
+ end
113
+ sleep(attempt + 1) if attempt < 2
114
+ end
115
+ @logger.error("visionary: batch write failed; #{batch.size} entries dropped: #{err.message}")
116
+ end
117
+
118
+ def statements(kind, item, users)
119
+ if kind == :request
120
+ out = [stmt(:request, request_row(item, users))]
121
+ item.span_rows&.each { |r| out << stmt(:span, r) }
122
+ item.error_rows&.each { |r| out << stmt(:error, r) }
123
+ out
124
+ else
125
+ [stmt(kind, item)]
126
+ end
127
+ end
128
+
129
+ def stmt(kind, row)
130
+ [@sql.fetch(kind), Store.fit(@store.dialect, INSERTS.fetch(kind), row)]
131
+ end
132
+
133
+ # Resolves each distinct user missing a handle or role once per batch.
134
+ def lookup_users(batch)
135
+ out = {}
136
+ lookup = @cfg.lookup_user
137
+ return out unless lookup
138
+ batch.each do |kind, e|
139
+ next unless kind == :request
140
+ id = e.user_id
141
+ next if id.empty? || (!e.user_handle.empty? && !e.user_role.empty?) || out.key?(id)
142
+ found = begin
143
+ lookup.call(id)
144
+ rescue StandardError => ex
145
+ @logger.warn("visionary: lookup_user(#{id.inspect}) failed: #{ex.message}")
146
+ nil
147
+ end
148
+ out[id] = found ? [found[0].to_s, found[1].to_s] : ["", ""]
149
+ end
150
+ out
151
+ end
152
+
153
+ def request_row(e, users)
154
+ handle = e.user_handle
155
+ role = e.user_role
156
+ if (found = users[e.user_id])
157
+ handle = found[0] if handle.empty?
158
+ role = found[1] if role.empty?
159
+ end
160
+ req_raw = Visionary.utf8(e.request_body)
161
+ resp_raw = Visionary.utf8(e.response_body)
162
+ code = key = ""
163
+ code, key = Redactor.error_fields(resp_raw, @cfg.error_code_field, @cfg.error_key_field) if e.status >= 400
164
+ req_body = resp_body = ""
165
+ if @cfg.capture_bodies
166
+ req_body = @redactor.request_body(req_raw, e.request_content_type)
167
+ resp_body = @redactor.response_body(resp_raw)
168
+ end
169
+ db_count, db_ms, http_count, http_ms = e.totals
170
+ [
171
+ e.request_id, Visionary.timestamp(e.started_at), e.method, e.path, nz(e.route), nz(e.query),
172
+ e.status, e.duration_ms, nz(e.ip), nz(e.user_agent), nz(e.user_id), nz(handle), nz(role),
173
+ nz(@redactor.headers(e.headers)), nz(e.request_content_type), nz(req_body), e.request_truncated ? 1 : 0,
174
+ e.request_size, nz(e.response_content_type), nz(resp_body), e.response_truncated ? 1 : 0, e.response_size,
175
+ nz(code), nz(key), @cfg.service, @cfg.version, db_count, db_ms, http_count, http_ms
176
+ ]
177
+ end
178
+
179
+ def nz(s)
180
+ s.nil? || s.empty? ? nil : s
181
+ end
182
+
183
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+ end
data/lib/visionary.rb ADDED
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "visionary/version"
4
+ require_relative "visionary/config"
5
+ require_relative "visionary/schema"
6
+ require_relative "visionary/store"
7
+ require_relative "visionary/schemas"
8
+ require_relative "visionary/redactor"
9
+ require_relative "visionary/context"
10
+ require_relative "visionary/queue"
11
+ require_relative "visionary/writer"
12
+ require_relative "visionary/retention"
13
+ require_relative "visionary/system/sources"
14
+ require_relative "visionary/system/recorder"
15
+ require_relative "visionary/telemetry"
16
+ require_relative "visionary/telemetry/hooks"
17
+ require_relative "visionary/body"
18
+ require_relative "visionary/middleware"
19
+ require_relative "visionary/agent"
20
+ require_relative "visionary/fork"
21
+
22
+ # Visionary collects what the Visionary server analyses, straight from a Ruby
23
+ # service: one api_logs row per HTTP request, spans for database and outbound
24
+ # HTTP calls, errors, deploy markers and periodic system_metrics samples.
25
+ #
26
+ # # config.ru (Sinatra, Roda, Hanami, any Rack app)
27
+ # require "visionary"
28
+ # use Visionary::Middleware
29
+ #
30
+ # In Rails the Railtie inserts the middleware. Everything is configured
31
+ # through VISIONARY_* environment variables, or Visionary.configure.
32
+ module Visionary
33
+ @lock = Mutex.new
34
+ @agent = nil
35
+ @config = nil
36
+
37
+ class << self
38
+ # The configuration the agent starts with (from the environment unless
39
+ # set). Change it before the first request, e.g. in an initializer.
40
+ def config
41
+ @config ||= Config.from_env
42
+ end
43
+
44
+ attr_writer :config
45
+
46
+ def configure
47
+ yield config
48
+ config
49
+ end
50
+
51
+ # Builds and starts the process-wide agent; later calls return it. Never
52
+ # raises: on any problem the agent is a no-op.
53
+ def start(cfg = nil)
54
+ return @agent if @agent
55
+ @lock.synchronize do
56
+ return @agent if @agent
57
+ @config = cfg if cfg
58
+ agent = begin
59
+ Agent.new(config).start
60
+ rescue StandardError => e
61
+ warn("visionary: start failed (#{e.class}: #{e.message}); collection is off")
62
+ Agent.new(Config.new.tap { |c| c.log_requests = c.system_metrics = c.spans = c.errors = false })
63
+ end
64
+ at_exit { agent.close }
65
+ @agent = agent
66
+ end
67
+ end
68
+
69
+ # The running agent, started on first use.
70
+ def agent = @agent || start
71
+
72
+ # The agent's span and error recorder, or nil when collection is off.
73
+ def telemetry = @agent&.telemetry
74
+
75
+ # Flushes and stops collection, waiting at most timeout seconds.
76
+ def close(timeout = 10)
77
+ @agent&.close(timeout)
78
+ end
79
+
80
+ # Attaches the signed-in user to the request being logged. Call it from
81
+ # authentication code (a before_action, a Warden callback, a Rack
82
+ # middleware) once the user is known. Only id is required. No-op outside a
83
+ # request seen by Visionary::Middleware.
84
+ def set_user(id, handle: nil, role: nil)
85
+ RequestContext.current&.set_user(id, handle, role)
86
+ nil
87
+ end
88
+
89
+ # Records the route pattern (e.g. "/users/:id") for routers Visionary
90
+ # can't see. No-op outside a request.
91
+ def set_route(pattern)
92
+ ctx = RequestContext.current
93
+ ctx.route = pattern if ctx
94
+ nil
95
+ end
96
+
97
+ # Measures the block as a span and returns its value. kind is "custom",
98
+ # "cache", "queue", "db" or "http"; name should be low-cardinality (the
99
+ # operation, not its arguments).
100
+ #
101
+ # Visionary.span("charge card", kind: "http", target: "api.stripe.com") { stripe.charge(...) }
102
+ def span(name, kind: "custom", target: nil, detail: nil)
103
+ rec = telemetry
104
+ return yield unless rec&.spans_on
105
+
106
+ t0 = Telemetry.now_ms
107
+ status = "ok"
108
+ error = nil
109
+ begin
110
+ yield
111
+ rescue Exception => e # re-raised; only observed here
112
+ status = "error"
113
+ error = "#{e.class}: #{e.message}"
114
+ raise
115
+ ensure
116
+ rec.span(kind: kind, name: name, target: target, detail: detail, start_mono: t0,
117
+ duration_ms: Telemetry.now_ms - t0, status: status, error: error)
118
+ end
119
+ end
120
+
121
+ # Records a handled error (handled = 1). Inside a request it links to it.
122
+ #
123
+ # rescue PaymentError => e
124
+ # Visionary.capture_exception(e)
125
+ def capture_exception(exception = $!)
126
+ telemetry&.exception(exception, handled: true)
127
+ nil
128
+ end
129
+
130
+ # Restarts the background threads in a forked child. Called
131
+ # automatically through Process._fork; Puma's on_worker_boot may call it
132
+ # too (it is idempotent per process).
133
+ def after_fork
134
+ agent = @agent
135
+ return unless agent
136
+ @lock = Mutex.new
137
+ agent.after_fork
138
+ rescue StandardError => e
139
+ warn("visionary: restart after fork failed: #{e.message}")
140
+ end
141
+
142
+ # created_at format for every table: UTC, milliseconds, literal Z.
143
+ def timestamp(time)
144
+ time.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
145
+ end
146
+
147
+ # Bytes as UTF-8 text, invalid sequences replaced.
148
+ def utf8(s)
149
+ return "" if s.nil?
150
+ s = s.to_s
151
+ return s if s.encoding == Encoding::UTF_8 && s.valid_encoding?
152
+ s.dup.force_encoding(Encoding::UTF_8).scrub
153
+ end
154
+ end
155
+ end
156
+
157
+ require_relative "visionary/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: visionary-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - styropyr0
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ The Ruby collector for Visionary. A Rack middleware (with a Railtie for Rails)
15
+ writes one row per HTTP request, database and outbound HTTP spans, errors,
16
+ deploy markers and periodic host/process samples to PostgreSQL, MySQL,
17
+ SQLite or Cloudflare D1, on background threads.
18
+ email:
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/visionary.rb
27
+ - lib/visionary/agent.rb
28
+ - lib/visionary/body.rb
29
+ - lib/visionary/config.rb
30
+ - lib/visionary/context.rb
31
+ - lib/visionary/fork.rb
32
+ - lib/visionary/middleware.rb
33
+ - lib/visionary/queue.rb
34
+ - lib/visionary/railtie.rb
35
+ - lib/visionary/redactor.rb
36
+ - lib/visionary/retention.rb
37
+ - lib/visionary/schema.rb
38
+ - lib/visionary/schema/mysql/requests.sql
39
+ - lib/visionary/schema/mysql/system.sql
40
+ - lib/visionary/schema/mysql/telemetry.sql
41
+ - lib/visionary/schema/postgres/requests.sql
42
+ - lib/visionary/schema/postgres/system.sql
43
+ - lib/visionary/schema/postgres/telemetry.sql
44
+ - lib/visionary/schema/sqlite/requests.sql
45
+ - lib/visionary/schema/sqlite/system.sql
46
+ - lib/visionary/schema/sqlite/telemetry.sql
47
+ - lib/visionary/schemas.rb
48
+ - lib/visionary/store.rb
49
+ - lib/visionary/store/active_record.rb
50
+ - lib/visionary/store/d1.rb
51
+ - lib/visionary/store/sql.rb
52
+ - lib/visionary/system/recorder.rb
53
+ - lib/visionary/system/sources.rb
54
+ - lib/visionary/telemetry.rb
55
+ - lib/visionary/telemetry/errors.rb
56
+ - lib/visionary/telemetry/hooks.rb
57
+ - lib/visionary/telemetry/sql_text.rb
58
+ - lib/visionary/version.rb
59
+ - lib/visionary/writer.rb
60
+ homepage: https://github.com/styropyr0/Visionary
61
+ licenses:
62
+ - Apache-2.0
63
+ metadata:
64
+ homepage_uri: https://github.com/styropyr0/Visionary
65
+ source_code_uri: https://github.com/styropyr0/Visionary/tree/main/sdks/ruby
66
+ changelog_uri: https://github.com/styropyr0/Visionary/blob/main/sdks/ruby/CHANGELOG.md
67
+ bug_tracker_uri: https://github.com/styropyr0/Visionary/issues
68
+ documentation_uri: https://github.com/styropyr0/Visionary/tree/main/sdks/ruby#readme
69
+ rubygems_mfa_required: 'true'
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.22
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Request logs, spans, errors and system metrics for Visionary, from Rack and
89
+ Rails apps
90
+ test_files: []