gritz-otel 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3c13de06e63ce826aa5008be7098af391057f6a1c37fc5f48c09cf4c2e0a2a53
4
+ data.tar.gz: 9d6dc0a2a2918d253944a33f239cf65294a43e08383373c1a0dfd5f3fe0f57bb
5
+ SHA512:
6
+ metadata.gz: 2e2f301f80ccbc3274444719b05d3f431456baa300753ef4d5a1ceef91b13470a259486538a26a875c1122d40d7042239fba1d4c295367520b63dc0b1499c5d0
7
+ data.tar.gz: 1346f9c98c2bce145ccbc172dc054f1183d7661c86d3591df14f84f9158ee1b870eebb86efe810b3f2905811ca2e3d6700e0734f8cbb97eb6932ce18acbd526c
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Gritz OpenTelemetry
2
+
3
+ Server and client spans and worker OTLP metrics for [Gritz](https://github.com/gritzrpc/gritz). SDK providers and exporters start in worker boot hooks, after fork. This optional integration depends on `gritz-core`; applications choose their transport separately.
4
+
5
+ Requires CRuby 3.3 or later and Gritz 0.4.0. The OpenTelemetry metrics SDK is currently alpha; this gem pins its supported minor versions.
6
+
7
+ ## Installation and configuration
8
+
9
+ ```ruby
10
+ gem "gritz", "~> 0.4.0"
11
+ gem "gritz-otel", git: "https://github.com/gritzrpc/gritz-otel.git", branch: "main"
12
+ ```
13
+
14
+ The first `gritz-otel` release is being prepared. Until it is published, use the source dependency above. After publication, use `gem "gritz-otel", "~> 0.1.0"`.
15
+
16
+ In the Gritz configuration file:
17
+
18
+ ```ruby
19
+ require "gritz/otel"
20
+
21
+ metrics_backend :otlp
22
+ opentelemetry do |sdk|
23
+ sdk.service_name = "greeter"
24
+ end
25
+ ```
26
+
27
+ Set `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318/` for OTLP/HTTP protobuf. Standard exporter endpoint, headers, certificate and compression environment variables are supported. Traces go to `/v1/traces` and metrics to `/v1/metrics`. With the default `metrics_backend :pipe`, the integration adds tracing while retaining the built-in Prometheus metrics.
28
+
29
+ For a configuration object, use `Gritz::Otel.install(config) { |sdk| ... }`. The SDK block executes in each worker; create custom processors or exporters inside that block. Register the integration before starting the server. The integration owns one SDK configuration per worker process and closes its providers after application shutdown hooks and final RPC observations.
30
+
31
+ ## Tracing
32
+
33
+ Server spans surround complete dispatch, including streaming and error mapping. `Gritz::Client` spans surround complete outgoing calls and response consumption, with native cancellation on early exit. Incoming W3C `traceparent` and `tracestate` are extracted and outgoing child contexts are injected. Lazy clients retain the context captured when the call was created. Incoming credentials and baggage are not automatically forwarded.
34
+
35
+ Spans include the service, method and final gRPC status. They omit protobuf payloads and exception messages. See the [client guide](https://github.com/gritzrpc/gritz-core/blob/main/docs/guides/clients.md).
36
+
37
+ ## Worker metrics
38
+
39
+ With `metrics_backend :otlp`, each worker records:
40
+
41
+ | Instrument | Meaning |
42
+ | --- | --- |
43
+ | `gritz.rpc.server.duration` | Completed RPC duration, in seconds |
44
+ | `gritz.rpc.server.requests`, `gritz.rpc.server.responses` | Messages per completed RPC |
45
+ | `gritz.rpc.server.rejected` | Overload rejections |
46
+ | `gritz.worker.inflight`, `gritz.worker.busy_threads`, `gritz.worker.capacity` | Worker occupancy and capacity |
47
+ | `gritz.worker.state` | Current lifecycle state |
48
+ | `gritz.worker.rss_bytes`, `gritz.worker.pss_bytes` | Actual Linux process memory |
49
+
50
+ Worker PID and index identify each observation; service/method/status identify RPC histograms. The collector receives cumulative SDK metrics from individual workers. Master restart counts and framework-wide totals remain available through Admin `/metrics`; OTLP continues to mirror RPC deltas to the built-in pipe backend.
51
+
52
+ Worker status heartbeats drive official SDK metric collection and HTTP export. Each heartbeat export is bounded by the smallest of one second, half `status_interval`, and a quarter of `shutdown_timeout`. A failed observation or export retains pipe data and does not fail the RPC. Final export and provider shutdown share the remaining graceful shutdown budget; telemetry may be dropped when that budget expires.
53
+
54
+ ## Development and releases
55
+
56
+ ```sh
57
+ bundle install
58
+ COVERAGE=1 bundle exec rake
59
+ bundle exec rubocop
60
+ bundle exec bundler-audit check --update
61
+ bundle exec rake build
62
+ ```
63
+
64
+ Tests include a real Linux three-service chain with an OTLP/HTTP protobuf collector. Core and native development dependencies use their Git repositories; runtime dependencies use published gems. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [release guide](docs/guides/releasing.md).
65
+
66
+ See the [three-service integration report](docs/reports/T4-08-client-chain.md) for the completion checks and reproduction commands.
67
+
68
+ The initial release is prepared for the project owner. Do not publish its first tag automatically.
69
+
70
+ ## License
71
+
72
+ [MIT](LICENSE.txt).
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Otel
5
+ # Preserve pipe deltas while recording worker-local SDK instruments.
6
+ class Recorder < Gritz::Metrics::Recorder
7
+ def initialize(worker:, exporter: nil, timeout: 1.0)
8
+ super()
9
+ @exporter = exporter
10
+ @timeout = timeout
11
+ @attributes = { "worker.index" => worker, "process.pid" => Process.pid }
12
+ @rejected = 0
13
+ return unless exporter
14
+
15
+ meter = OpenTelemetry.meter_provider.meter("gritz", version: VERSION)
16
+ @duration = meter.create_histogram("gritz.rpc.server.duration", unit: "s")
17
+ @requests = meter.create_histogram("gritz.rpc.server.requests", unit: "{message}")
18
+ @responses = meter.create_histogram("gritz.rpc.server.responses", unit: "{message}")
19
+ @rejections = meter.create_counter("gritz.rpc.server.rejected", unit: "{request}")
20
+ @gauges = %w[inflight busy_threads capacity rss_bytes pss_bytes].to_h do |name|
21
+ [name, meter.create_gauge("gritz.worker.#{name}")]
22
+ end
23
+ @state = meter.create_gauge("gritz.worker.state")
24
+ end
25
+
26
+ def record_rpc(service:, method:, code:, duration:, requests:, responses:)
27
+ super
28
+ return unless @exporter
29
+
30
+ attributes = @attributes.merge("rpc.system" => "grpc", "rpc.service" => service, "rpc.method" => method, "rpc.grpc.status_code" => code)
31
+ observe_telemetry do
32
+ @duration.record(duration, attributes:)
33
+ @requests.record(requests, attributes:)
34
+ @responses.record(responses, attributes:)
35
+ end
36
+ end
37
+
38
+ def observe_rejected(total)
39
+ super
40
+ observe_telemetry do
41
+ @rejections&.add(total - @rejected, attributes: @attributes)
42
+ @rejected = total
43
+ end
44
+ end
45
+
46
+ def observe_worker(status)
47
+ return unless @exporter
48
+
49
+ observe_telemetry do
50
+ @gauges.each { |name, gauge| gauge.record(status.fetch(name.to_sym, 0), attributes: @attributes) unless name.end_with?("_bytes") }
51
+ memory.each { |name, value| @gauges.fetch(name).record(value, attributes: @attributes) }
52
+ %w[booting ready draining failed stopped].each do |state|
53
+ @state.record(state == status[:state] ? 1 : 0, attributes: @attributes.merge("state" => state))
54
+ end
55
+ # The official exporter is also a MetricReader. Heartbeats drive bounded exports,
56
+ # avoiding the alpha SDK periodic reader's unbounded shutdown join.
57
+ @exporter.export(@exporter.collect, timeout: @timeout) unless status[:state] == "stopped"
58
+ end
59
+ end
60
+
61
+ def close(timeout: @timeout)
62
+ Otel.shutdown(timeout:)
63
+ end
64
+
65
+ private
66
+
67
+ def observe_telemetry
68
+ yield
69
+ rescue StandardError => e
70
+ OpenTelemetry.logger.warn("Gritz metrics observation failed (#{e.class})")
71
+ end
72
+
73
+ def memory
74
+ return {} unless File.readable?("/proc/self/smaps_rollup")
75
+
76
+ File.read("/proc/self/smaps_rollup").scan(/^(Rss|Pss):\s+(\d+)\s+kB$/).to_h.transform_keys { |key| "#{key.downcase}_bytes" }
77
+ .transform_values { |value| value.to_i * 1024 }
78
+ rescue SystemCallError
79
+ {}
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Otel
5
+ # @api private
6
+ module Tracing
7
+ def self.within(context, kind:, parent:, &block)
8
+ OpenTelemetry::Context.with_current(parent) do
9
+ span = start_span(context, kind)
10
+ code = 0
11
+ begin
12
+ OpenTelemetry::Trace.with_span(span, &block)
13
+ rescue Gritz::Error => e
14
+ code = e.grpc_code
15
+ raise
16
+ rescue StandardError
17
+ code = 13
18
+ raise
19
+ ensure
20
+ code = 1 if code.zero? && context.cancelled?
21
+ finish_span(span, code)
22
+ end
23
+ end
24
+ end
25
+
26
+ def self.start_span(context, kind)
27
+ attributes = { "rpc.system" => "grpc", "rpc.service" => context.method.service, "rpc.method" => context.method.name }
28
+ OpenTelemetry.tracer_provider.tracer("gritz", VERSION).start_span(context.method.full_name, kind:, attributes:)
29
+ rescue StandardError => e
30
+ OpenTelemetry.logger.warn("Gritz tracing failed to start (#{e.class})")
31
+ OpenTelemetry::Trace::Span::INVALID
32
+ end
33
+
34
+ def self.finish_span(span, code)
35
+ span.set_attribute("rpc.grpc.status_code", code)
36
+ span.status = OpenTelemetry::Trace::Status.error("RPC failed") unless code.zero?
37
+ span.finish
38
+ rescue StandardError => e
39
+ OpenTelemetry.logger.warn("Gritz tracing failed to finish (#{e.class})")
40
+ end
41
+ end
42
+
43
+ # Wraps the complete server dispatch, including streaming and error mapping.
44
+ class ServerTracing
45
+ def initialize(app) = @app = app
46
+
47
+ def call(context)
48
+ return @app.call(context) if ENV["OTEL_SDK_DISABLED"] == "true"
49
+
50
+ carrier = context.metadata.slice("traceparent", "tracestate").transform_values do |value|
51
+ value.is_a?(Array) ? value.join(",") : value
52
+ end
53
+ parent = OpenTelemetry.propagation.extract(carrier, context: OpenTelemetry::Context.empty)
54
+ Tracing.within(context, kind: :server, parent:) do
55
+ context.trace_context = OpenTelemetry::Context.current
56
+ @app.call(context)
57
+ end
58
+ end
59
+ end
60
+
61
+ # Capture the caller when the invocation is created, before lazy stream consumption.
62
+ class ClientTracing
63
+ def initialize(app)
64
+ @app = app
65
+ @parent = OpenTelemetry::Context.current
66
+ end
67
+
68
+ def call(context)
69
+ return @app.call(context) if ENV["OTEL_SDK_DISABLED"] == "true"
70
+
71
+ parent = context.parent&.trace_context
72
+ parent = @parent unless parent.is_a?(OpenTelemetry::Context)
73
+ Tracing.within(context, kind: :client, parent:) do
74
+ context.trace_context = OpenTelemetry::Context.current
75
+ carrier = {}
76
+ OpenTelemetry.propagation.inject(carrier)
77
+ context.metadata.delete("traceparent")
78
+ context.metadata.delete("tracestate")
79
+ context.metadata.merge!(carrier.slice("traceparent", "tracestate"))
80
+ @app.call(context)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Otel
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/gritz/otel.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gritz/core"
4
+ require "opentelemetry-api"
5
+ require_relative "otel/version"
6
+ require_relative "otel/tracing"
7
+ require_relative "otel/recorder"
8
+
9
+ module Gritz
10
+ module Otel
11
+ # Configuration-file syntax for this optional integration.
12
+ module ConfigurationDSL
13
+ def opentelemetry(&) = Otel.install(@config, &)
14
+ end
15
+
16
+ class << self
17
+ # Registration is safe before fork; SDK providers/exporters are built in the worker hook.
18
+ def install(config, &configure)
19
+ return config if config.middleware.entries.any? { |entry| entry.middleware == ServerTracing }
20
+ raise Gritz::ConfigurationError, "a metrics recorder is already installed" if config.metrics_recorder_factory
21
+
22
+ config.middleware.insert_after(Gritz::Middleware::Context, ServerTracing)
23
+ unless Gritz::Client.middleware.entries.any? { |entry| entry.middleware == ClientTracing }
24
+ Gritz::Client.middleware.use(ClientTracing)
25
+ end
26
+ config.add_hook(:on_worker_boot) { bootstrap(config, &configure) }
27
+ config.metrics_recorder_factory = ->(worker:) { Recorder.new(worker:, exporter: @metrics_exporter, timeout: export_timeout(config)) }
28
+ config
29
+ end
30
+
31
+ # @api private
32
+ def bootstrap(config, &configure)
33
+ return if @pid == Process.pid
34
+
35
+ @metrics_exporter = @tracer_provider = @meter_provider = nil
36
+ return if ENV["OTEL_SDK_DISABLED"] == "true"
37
+
38
+ require "opentelemetry/sdk"
39
+ require "opentelemetry/exporter/otlp"
40
+ if config.metrics_backend == :otlp
41
+ require "opentelemetry-metrics-sdk"
42
+ require "opentelemetry-exporter-otlp-metrics"
43
+ @metrics_exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new
44
+ end
45
+ # SDK.configure swallows boot exceptions; the same official configurator
46
+ # lets an invalid worker configuration reach Gritz's startup failure path.
47
+ sdk = OpenTelemetry::SDK::Configurator.new
48
+ sdk.resource = OpenTelemetry::SDK::Resources::Resource.create("process.pid" => Process.pid, "service.instance.id" => Process.pid.to_s)
49
+ sdk.add_metric_reader(@metrics_exporter) if @metrics_exporter
50
+ # The metrics SDK patches the process-wide configurator once it is loaded.
51
+ # A trace-only worker must not create its default background metric reader.
52
+ if !@metrics_exporter && sdk.respond_to?(:add_metric_reader)
53
+ sdk.add_metric_reader(OpenTelemetry::SDK::Metrics::Export::MetricReader.new)
54
+ end
55
+ configure&.call(sdk)
56
+ sdk.configure
57
+ @tracer_provider = OpenTelemetry.tracer_provider
58
+ @meter_provider = OpenTelemetry.meter_provider if OpenTelemetry.respond_to?(:meter_provider)
59
+ if @metrics_exporter
60
+ { "duration" => Metrics::Recorder::DURATION_BUCKETS, "requests" => Metrics::Recorder::MESSAGE_BUCKETS,
61
+ "responses" => Metrics::Recorder::MESSAGE_BUCKETS }.each do |name, bounds|
62
+ aggregation = OpenTelemetry::SDK::Metrics::Aggregation::ExplicitBucketHistogram.new(boundaries: bounds.select(&:finite?))
63
+ @meter_provider.add_view("gritz.rpc.server.#{name}", aggregation:, type: :histogram, meter_name: "gritz", meter_version: VERSION)
64
+ end
65
+ end
66
+ @pid = Process.pid
67
+ end
68
+
69
+ # @api private
70
+ def shutdown(timeout:)
71
+ return unless @pid == Process.pid
72
+
73
+ @pid = nil
74
+ deadline = monotonic + timeout
75
+ if @metrics_exporter && timeout.positive?
76
+ shutdown_component { @metrics_exporter.export(@metrics_exporter.collect, timeout: timeout / 2.0) }
77
+ end
78
+ shutdown_component { @tracer_provider&.shutdown(timeout: [deadline - monotonic, 0].max) }
79
+ ensure
80
+ shutdown_component { @meter_provider&.shutdown(timeout: [deadline - monotonic, 0].max) } if deadline
81
+ @metrics_exporter = @tracer_provider = @meter_provider = nil if deadline
82
+ end
83
+
84
+ # Keep a slow collector within the worker's heartbeat and shutdown budgets.
85
+ # @api private
86
+ def export_timeout(config) = [1.0, config.status_interval / 2.0, config.shutdown_timeout / 4.0].min
87
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+
89
+ private
90
+
91
+ def shutdown_component
92
+ yield
93
+ rescue StandardError => e
94
+ OpenTelemetry.logger.warn("Gritz telemetry shutdown failed (#{e.class})")
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ Gritz::DSL.prepend(Gritz::Otel::ConfigurationDSL)
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gritz-otel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
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: gritz-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.4.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.4.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: opentelemetry-exporter-otlp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.35.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.35.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: opentelemetry-exporter-otlp-metrics
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.13.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.13.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: opentelemetry-metrics-sdk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 0.19.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.19.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: opentelemetry-sdk
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 1.13.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 1.13.0
82
+ description: Server and client tracing and worker OTLP metrics for Gritz, initialized
83
+ after fork.
84
+ email:
85
+ - t.yudai92@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE.txt
92
+ - README.md
93
+ - lib/gritz/otel.rb
94
+ - lib/gritz/otel/recorder.rb
95
+ - lib/gritz/otel/tracing.rb
96
+ - lib/gritz/otel/version.rb
97
+ homepage: https://github.com/gritzrpc/gritz-otel
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ allowed_push_host: https://rubygems.org
102
+ homepage_uri: https://github.com/gritzrpc/gritz-otel/
103
+ source_code_uri: https://github.com/gritzrpc/gritz-otel
104
+ changelog_uri: https://github.com/gritzrpc/gritz-otel/blob/main/CHANGELOG.md
105
+ rubygems_mfa_required: 'true'
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.3'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 4.0.16
121
+ specification_version: 4
122
+ summary: Fork-safe OpenTelemetry integration for Gritz
123
+ test_files: []