rivulet-opentelemetry 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f0c84121fc0e40a81c56e2292f6b39e38b42333471d0a05bcd0460ef90ee486d
4
+ data.tar.gz: 756ffd299bb007a24f8531b65b12922559f671f1e392b092e4a8e18e99b2afd0
5
+ SHA512:
6
+ metadata.gz: 2e47f6a0535d7aaf373dd789ce7ba457b07e035a56bceca7e39633d22a8574f0eef06cbc0d100fa3675bceb5a0455e7bf1e43f4cdae1e230def63c232bcc2f0d
7
+ data.tar.gz: 20767ee6b59e5be4d974620f021c7432e23c730486f9a578dce2385786d6b0cf213da305beb341796a7d38098ea02c87aee8b8ed075d20bc1af2cc83592add9e
data/bin/rivulet-otel ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/rivulet/otel/cli'
3
+
4
+ Dry::CLI.new(Rivulet::OTel::CLI::Commands).call
@@ -0,0 +1,5 @@
1
+ module Rivulet
2
+ module OTel
3
+ VERSION = '0.2.0'
4
+ end
5
+ end
@@ -0,0 +1,53 @@
1
+ require 'opentelemetry/sdk'
2
+ require 'opentelemetry/exporter/otlp'
3
+ require 'opentelemetry/instrumentation/rack'
4
+ require 'opentelemetry/sdk/logs'
5
+ require 'opentelemetry/exporter/otlp_logs'
6
+ require 'opentelemetry-metrics-sdk'
7
+ require 'opentelemetry/exporter/otlp_metrics'
8
+
9
+ require_relative 'opentelemetry/version'
10
+ require_relative 'otel/sink'
11
+ require_relative 'otel/logger'
12
+ require_relative 'otel/metrics'
13
+
14
+ module Rivulet
15
+ module OTel
16
+ class << self
17
+ def configure(service_name: 'rivulet')
18
+ return unless ENV['OTEL_EXPORTER_OTLP_ENDPOINT']
19
+
20
+ ENV['OTEL_METRICS_EXPORTER'] ||= 'none'
21
+
22
+ OpenTelemetry::SDK.configure do |c|
23
+ c.service_name = service_name
24
+ c.use 'OpenTelemetry::Instrumentation::Rack'
25
+ end
26
+
27
+ configure_logs(service_name)
28
+ configure_metrics
29
+ end
30
+
31
+ private
32
+
33
+ def configure_logs(service_name)
34
+ logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new
35
+ logger_provider.add_log_record_processor(
36
+ OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(
37
+ OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new
38
+ )
39
+ )
40
+
41
+ OpenTelemetry.instance_variable_set(:@logger_provider, logger_provider)
42
+ logger_provider.logger(name: service_name)
43
+ end
44
+
45
+ def configure_metrics
46
+ exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new
47
+ OpenTelemetry.meter_provider.add_metric_reader(
48
+ OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter)
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,120 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Rivulet
5
+ module OTel
6
+ module CLI
7
+ module Commands
8
+ class Setup < Dry::CLI::Command
9
+ desc "Wire OpenTelemetry into an existing Rivulet app"
10
+
11
+ def call(**)
12
+ validate_app!
13
+
14
+ generate_files
15
+ patch_application_config
16
+ patch_docker_compose
17
+
18
+ puts "\nDone! Next steps:"
19
+ puts " docker compose up --build"
20
+ puts " Open Grafana at http://localhost:3000 (admin/admin)"
21
+ end
22
+
23
+ private
24
+
25
+ def validate_app!
26
+ unless File.exist?('config.ru') && File.read('config.ru').include?("require 'rivulet'")
27
+ puts "Error: not a Rivulet application (config.ru not found or missing 'require \"rivulet\"')"
28
+ exit 1
29
+ end
30
+
31
+ unless File.exist?('config/application.rb')
32
+ puts "Error: config/application.rb not found"
33
+ exit 1
34
+ end
35
+ end
36
+
37
+ def generate_files
38
+ FileUtils.mkdir_p('config/initializers')
39
+
40
+ write 'config/initializers/opentelemetry.rb', opentelemetry_initializer
41
+ end
42
+
43
+ def write(path, content)
44
+ File.write(path, content)
45
+ puts " create #{path}"
46
+ end
47
+
48
+ def patch_application_config
49
+ path = 'config/application.rb'
50
+ content = File.read(path)
51
+ changed = false
52
+
53
+ unless content.include?('config.telemetry.sink = Rivulet::OTel::Sink.new')
54
+ content = content.sub(/(end\s*\n?\z)/, " config.telemetry.sink = Rivulet::OTel::Sink.new\n\\1")
55
+ changed = true
56
+ end
57
+
58
+ unless content.include?('config.logger.engine = Rivulet::OTel::Logger.new')
59
+ content = content.sub(/(end\s*\n?\z)/, " config.logger.engine = Rivulet::OTel::Logger.new\n\\1")
60
+ changed = true
61
+ end
62
+
63
+ if changed
64
+ File.write(path, content)
65
+ puts " patch #{path}"
66
+ else
67
+ puts " skip #{path} (already patched)"
68
+ end
69
+ end
70
+
71
+ def patch_docker_compose
72
+ path = 'docker-compose.yml'
73
+ return puts " skip #{path} (not found)" unless File.exist?(path)
74
+
75
+ compose = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
76
+ return puts " skip #{path} (already patched)" if compose.dig('services', 'lgtm')
77
+
78
+ app_env = compose.dig('services', 'app', 'environment')
79
+ if app_env.is_a?(Hash)
80
+ app_env['OTEL_EXPORTER_OTLP_ENDPOINT'] = 'http://lgtm:4318'
81
+ elsif app_env.is_a?(Array)
82
+ app_env << 'OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318'
83
+ else
84
+ compose['services']['app']['environment'] = { 'OTEL_EXPORTER_OTLP_ENDPOINT' => 'http://lgtm:4318' }
85
+ end
86
+
87
+ app_depends = compose.dig('services', 'app', 'depends_on')
88
+ if app_depends.is_a?(Hash)
89
+ app_depends['lgtm'] = { 'condition' => 'service_started' }
90
+ elsif app_depends.is_a?(Array)
91
+ app_depends << 'lgtm'
92
+ else
93
+ compose['services']['app']['depends_on'] = { 'lgtm' => { 'condition' => 'service_started' } }
94
+ end
95
+
96
+ compose['services']['lgtm'] = lgtm_service
97
+
98
+ File.write(path, YAML.dump(compose, line_width: -1))
99
+ puts " patch #{path}"
100
+ end
101
+
102
+ def lgtm_service
103
+ {
104
+ 'image' => 'grafana/otel-lgtm:latest',
105
+ 'ports' => ['3000:3000', '4318:4318']
106
+ }
107
+ end
108
+
109
+ def opentelemetry_initializer
110
+ <<~RUBY
111
+ require 'rivulet/opentelemetry'
112
+
113
+ Rivulet::OTel.configure(service_name: Rivulet.config.app.name)
114
+ RUBY
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,14 @@
1
+ require 'dry/cli'
2
+ require_relative 'cli/setup'
3
+
4
+ module Rivulet
5
+ module OTel
6
+ module CLI
7
+ module Commands
8
+ extend Dry::CLI::Registry
9
+
10
+ register 'setup', Commands::Setup
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,68 @@
1
+ module Rivulet
2
+ module OTel
3
+ class Logger
4
+ SEVERITY_MAP = {
5
+ debug: { text: 'DEBUG', number: 5 },
6
+ info: { text: 'INFO', number: 9 },
7
+ warn: { text: 'WARN', number: 13 },
8
+ error: { text: 'ERROR', number: 17 },
9
+ fatal: { text: 'FATAL', number: 21 },
10
+ }.freeze
11
+
12
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
13
+
14
+ def initialize(level: :info)
15
+ @level = LEVELS.fetch(level, 1)
16
+ end
17
+
18
+ def debug(message = nil, &block)
19
+ log(:debug, message, &block)
20
+ end
21
+
22
+ def info(message = nil, &block)
23
+ log(:info, message, &block)
24
+ end
25
+
26
+ def warn(message = nil, &block)
27
+ log(:warn, message, &block)
28
+ end
29
+
30
+ def error(message = nil, &block)
31
+ log(:error, message, &block)
32
+ end
33
+
34
+ def fatal(message = nil, &block)
35
+ log(:fatal, message, &block)
36
+ end
37
+
38
+ private
39
+
40
+ def log(severity, message = nil, &block)
41
+ return if LEVELS.fetch(severity, 1) < @level
42
+
43
+ body = (message || (block && block.call)).to_s
44
+ return if body.empty?
45
+
46
+ write_stdout(severity, body)
47
+ emit_otel(severity, body)
48
+ end
49
+
50
+ def write_stdout(severity, body)
51
+ ts = Time.now.strftime('%Y-%m-%d %H:%M:%S %z')
52
+ $stdout.puts("[#{severity.to_s.upcase}] #{ts} #{body}")
53
+ end
54
+
55
+ def emit_otel(severity, body)
56
+ otel_logger = OpenTelemetry.instance_variable_get(:@logger_provider)&.logger(name: 'rivulet')
57
+ return unless otel_logger
58
+
59
+ sev = SEVERITY_MAP.fetch(severity, SEVERITY_MAP[:info])
60
+ otel_logger.on_emit(
61
+ body: body,
62
+ severity_text: sev[:text],
63
+ severity_number: sev[:number]
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ module Rivulet
2
+ module OTel
3
+ class Metrics
4
+ def initialize
5
+ meter = OpenTelemetry.meter_provider.meter('rivulet')
6
+
7
+ @request_count = meter.create_counter(
8
+ 'rivulet.request.count',
9
+ unit: '1',
10
+ description: 'Total number of requests'
11
+ )
12
+
13
+ @request_duration = meter.create_histogram(
14
+ 'rivulet.request.duration_ms',
15
+ unit: 'ms',
16
+ description: 'Request duration in milliseconds'
17
+ )
18
+
19
+ @db_queries = meter.create_counter(
20
+ 'rivulet.db.queries',
21
+ unit: '1',
22
+ description: 'Total number of database queries'
23
+ )
24
+
25
+ @db_duration = meter.create_histogram(
26
+ 'rivulet.db.duration_ms',
27
+ unit: 'ms',
28
+ description: 'Database query duration in milliseconds'
29
+ )
30
+
31
+ @step_duration = meter.create_histogram(
32
+ 'rivulet.step.duration_ms',
33
+ unit: 'ms',
34
+ description: 'Operation/step duration in milliseconds'
35
+ )
36
+ end
37
+
38
+ def record_request(total_ms)
39
+ @request_count.add(1)
40
+ @request_duration.record(total_ms)
41
+ end
42
+
43
+ def record_db(elapsed_ms)
44
+ @db_queries.add(1)
45
+ @db_duration.record(elapsed_ms)
46
+ end
47
+
48
+ def record_step(name, duration_ms)
49
+ @step_duration.record(duration_ms, attributes: { 'step.name' => name })
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ require 'opentelemetry'
2
+
3
+ module Rivulet
4
+ module OTel
5
+ class Sink
6
+ SPAN_STACK_KEY = :rivulet_otel_span_stack
7
+
8
+ def initialize(tracer: nil)
9
+ @tracer = tracer || OpenTelemetry.tracer_provider.tracer('rivulet')
10
+ @metrics = begin
11
+ Rivulet::OTel::Metrics.new
12
+ rescue
13
+ nil
14
+ end
15
+ end
16
+
17
+ def on_start(node, parent)
18
+ span = @tracer.start_span(node.name, kind: :internal)
19
+ span.set_attribute('rivulet.node.name', node.name)
20
+
21
+ ctx = OpenTelemetry::Trace.context_with_span(span)
22
+ token = OpenTelemetry::Context.attach(ctx)
23
+
24
+ stack = Fiber[SPAN_STACK_KEY] ||= []
25
+ stack.push(span: span, token: token)
26
+ end
27
+
28
+ def on_stop(node)
29
+ entry = (Fiber[SPAN_STACK_KEY] ||= []).pop
30
+ return unless entry
31
+
32
+ span = entry[:span]
33
+ token = entry[:token]
34
+
35
+ span.set_attribute('rivulet.duration_ms', node.duration_ms)
36
+ span.set_attribute('rivulet.self_ms', node.self_ms)
37
+
38
+ @metrics&.record_step(node.name, node.duration_ms)
39
+
40
+ span.finish
41
+ OpenTelemetry::Context.detach(token)
42
+ end
43
+
44
+ def on_db(elapsed_ms)
45
+ span = current_span
46
+ return unless span
47
+
48
+ span.add_event('db.query', attributes: { 'db.elapsed_ms' => elapsed_ms })
49
+ @metrics&.record_db(elapsed_ms)
50
+ end
51
+
52
+ def on_root(node, total_ms)
53
+ @metrics&.record_request(total_ms)
54
+ end
55
+
56
+ private
57
+
58
+ def current_span
59
+ stack = Fiber[SPAN_STACK_KEY]
60
+ return OpenTelemetry::Trace::Span.new unless stack&.any?
61
+
62
+ stack.last[:span]
63
+ end
64
+ end
65
+ end
66
+ end
metadata ADDED
@@ -0,0 +1,199 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rivulet-opentelemetry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Vladimir Dombrovskiy <vold@fastmail.com>
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rivulet-rb
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: opentelemetry-sdk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: opentelemetry-exporter-otlp
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: opentelemetry-instrumentation-rack
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: opentelemetry-logs-sdk
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: opentelemetry-exporter-otlp-logs
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: opentelemetry-metrics-sdk
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: opentelemetry-exporter-otlp-metrics
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: dry-cli
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rake
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ - !ruby/object:Gem::Dependency
153
+ name: rspec
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ executables:
167
+ - rivulet-otel
168
+ extensions: []
169
+ extra_rdoc_files: []
170
+ files:
171
+ - bin/rivulet-otel
172
+ - lib/rivulet/opentelemetry.rb
173
+ - lib/rivulet/opentelemetry/version.rb
174
+ - lib/rivulet/otel/cli.rb
175
+ - lib/rivulet/otel/cli/setup.rb
176
+ - lib/rivulet/otel/logger.rb
177
+ - lib/rivulet/otel/metrics.rb
178
+ - lib/rivulet/otel/sink.rb
179
+ licenses:
180
+ - Apache-2.0
181
+ metadata: {}
182
+ rdoc_options: []
183
+ require_paths:
184
+ - lib
185
+ required_ruby_version: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - ">="
188
+ - !ruby/object:Gem::Version
189
+ version: '3.3'
190
+ required_rubygems_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ requirements: []
196
+ rubygems_version: 4.0.20
197
+ specification_version: 4
198
+ summary: OpenTelemetry integration for Rivulet framework
199
+ test_files: []