dash0-opentelemetry 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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module Dash0
7
+ module OpenTelemetry
8
+ # Installs per-library instrumentation lazily via a `TracePoint(:end)`: an
9
+ # initial sweep for already-loaded libraries, then a re-sweep each time a class
10
+ # or module finishes being defined, so a library loaded after us is still
11
+ # caught.
12
+ #
13
+ # Ported, with light adaptation, from the upstream
14
+ # `opentelemetry-auto-instrumentation` gem's `OTelInitializer`
15
+ # (open-telemetry/opentelemetry-ruby-instrumentation), Apache-2.0.
16
+ module InstrumentationInstaller
17
+ @mutex = Mutex.new
18
+ @trace_point = nil
19
+
20
+ class << self
21
+ # Sweeps once for already-loaded libraries, then — unless everything
22
+ # relevant is already installed — installs a TracePoint(:end) that
23
+ # re-sweeps whenever a class or module finishes being defined.
24
+ #
25
+ # @param [Array<String>] enabled_names canonical instrumentation names to
26
+ # restrict installation to; empty means "all registered".
27
+ def start(enabled_names = enabled_instrumentation_names)
28
+ sweep(enabled_names)
29
+ return if relevant_instrumentations(enabled_names).all?(&:installed?)
30
+
31
+ @trace_point = TracePoint.new(:end) { sweep(enabled_names) }
32
+ @trace_point.enable
33
+ end
34
+
35
+ # Exposed for testing/inspection.
36
+ attr_reader :trace_point
37
+
38
+ # Returns the canonical instrumentation names enabled via
39
+ # OTEL_RUBY_ENABLED_INSTRUMENTATIONS, or [] when the variable is unset
40
+ # (meaning: install everything registered).
41
+ def enabled_instrumentation_names
42
+ raw = ENV['OTEL_RUBY_ENABLED_INSTRUMENTATIONS'].to_s
43
+ return [] if raw.strip.empty?
44
+
45
+ lookup = registry_lookup
46
+ raw.split(',').filter_map do |entry|
47
+ normalized = entry.strip.downcase
48
+ canonical = lookup[normalized]
49
+ if canonical.nil? && ENV['DASH0_DEBUG'] == 'true'
50
+ warn "#{Dash0::OpenTelemetry::LOG_PREFIX} Unknown instrumentation '#{entry.strip}'"
51
+ end
52
+ canonical
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Installs every relevant instrumentation that is installable right now.
59
+ def sweep(enabled_names)
60
+ @mutex.synchronize do
61
+ ready = relevant_instrumentations(enabled_names).select do |instrumentation|
62
+ ready_to_install?(instrumentation)
63
+ end
64
+ ::OpenTelemetry::Instrumentation.registry.install(ready.map(&:name)) unless ready.empty?
65
+ end
66
+ rescue StandardError => e
67
+ if ENV['DASH0_DEBUG'] == 'true'
68
+ warn "#{Dash0::OpenTelemetry::LOG_PREFIX} instrumentation sweep failed: #{e.message}"
69
+ end
70
+ end
71
+
72
+ # Whether this instrumentation is installable right now.
73
+ def ready_to_install?(instrumentation)
74
+ return false if instrumentation.installed?
75
+
76
+ instrumentation.present? && instrumentation.enabled? && instrumentation.compatible?
77
+ rescue StandardError, ScriptError
78
+ false
79
+ end
80
+
81
+ # The instrumentation instances this process should install: those named by
82
+ # +enabled_names+, or every registered instrumentation when +enabled_names+
83
+ # is empty.
84
+ def relevant_instrumentations(enabled_names)
85
+ registry = ::OpenTelemetry::Instrumentation.registry
86
+ if enabled_names.empty?
87
+ registry_instrumentation_classes.map(&:instance)
88
+ else
89
+ enabled_names.filter_map { |name| registry.lookup(name) }
90
+ end
91
+ rescue StandardError
92
+ []
93
+ end
94
+
95
+ # All instrumentation classes registered in the OpenTelemetry registry. The
96
+ # registry only exposes lookup/install publicly, so enumerate its internal
97
+ # collection to derive the supported set.
98
+ def registry_instrumentation_classes
99
+ registry = ::OpenTelemetry::Instrumentation.registry
100
+ registry.instance_variable_get(:@instrumentation) || []
101
+ rescue StandardError
102
+ []
103
+ end
104
+
105
+ # Builds and caches a hash mapping alias names (snake_case, no prefix) to
106
+ # canonical instrumentation names.
107
+ def registry_lookup
108
+ @registry_lookup ||= registry_instrumentation_classes.each_with_object({}) do |klass, lookup|
109
+ canonical = klass.instance.name
110
+ registry_aliases_for(canonical).each do |alias_name|
111
+ lookup[alias_name] ||= canonical
112
+ end
113
+ rescue StandardError
114
+ next
115
+ end
116
+ end
117
+
118
+ # All snake_case alias strings for a fully-qualified instrumentation name,
119
+ # e.g. "OpenTelemetry::Instrumentation::Net::HTTP" => ["net_http", ...].
120
+ def registry_aliases_for(instrumentation_name)
121
+ suffix = instrumentation_name.delete_prefix('OpenTelemetry::Instrumentation::')
122
+ segment_variants = suffix.split('::').map do |segment|
123
+ [snake_case(segment), segment.downcase].uniq
124
+ end
125
+
126
+ segment_variants.reduce(['']) do |aliases, variants|
127
+ aliases.flat_map do |prefix|
128
+ variants.map { |variant| prefix.empty? ? variant : "#{prefix}_#{variant}" }
129
+ end
130
+ end
131
+ end
132
+
133
+ def snake_case(value)
134
+ value
135
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
136
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
137
+ .tr('-', '_')
138
+ .downcase
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module Dash0
7
+ module OpenTelemetry
8
+ # Process-lifecycle concerns: an optional bootstrap span at startup, and
9
+ # graceful flushing of pending telemetry on shutdown.
10
+ module Lifecycle
11
+ BOOTSTRAP_SPAN_ENV = 'DASH0_BOOTSTRAP_SPAN'
12
+ FLUSH_ON_SIGNALS_ENV = 'DASH0_FLUSH_ON_SIGTERM_SIGINT'
13
+ FLUSH_ON_EXIT_ENV = 'DASH0_FLUSH_ON_EXIT'
14
+
15
+ # Name of the tracer used for the bootstrap span.
16
+ BOOTSTRAP_TRACER = 'dash0-ruby-distribution'
17
+
18
+ # Per-provider shutdown timeout (seconds). Bounds how long process exit can
19
+ # block flushing telemetry to a slow or unreachable collector.
20
+ SHUTDOWN_TIMEOUT_SECONDS = 2.0
21
+
22
+ # Signals whose default action would otherwise terminate the process without
23
+ # running `at_exit`, so we flush explicitly before re-raising.
24
+ FLUSH_SIGNALS = %w[TERM INT].freeze
25
+
26
+ @shutdown_mutex = Mutex.new
27
+ @shutdown_done = false
28
+
29
+ class << self
30
+ # Installs the configured lifecycle behaviors. Called once, after the SDK
31
+ # has been configured.
32
+ def install
33
+ name = bootstrap_span_name
34
+ create_bootstrap_span(name) if name
35
+ install_signal_handlers if flush_on_signals?
36
+ install_exit_handler if flush_on_exit?
37
+ end
38
+
39
+ # Gracefully shuts down the tracer, meter, and logger providers, flushing
40
+ # pending telemetry. Idempotent: only the first call performs the shutdown.
41
+ def shutdown
42
+ @shutdown_mutex.synchronize do
43
+ return if @shutdown_done
44
+
45
+ @shutdown_done = true
46
+ end
47
+
48
+ providers.each do |provider|
49
+ provider.shutdown(timeout: SHUTDOWN_TIMEOUT_SECONDS)
50
+ rescue StandardError => e
51
+ Dash0::OpenTelemetry.log_debug("Error during provider shutdown: #{e.message}")
52
+ end
53
+ end
54
+
55
+ def shutdown?
56
+ @shutdown_done
57
+ end
58
+
59
+ # Resets the one-time shutdown guard. Intended for tests only.
60
+ def reset_for_testing!
61
+ @shutdown_done = false
62
+ end
63
+
64
+ private
65
+
66
+ def bootstrap_span_name
67
+ Environment.present?(BOOTSTRAP_SPAN_ENV) ? ENV.fetch(BOOTSTRAP_SPAN_ENV).strip : nil
68
+ end
69
+
70
+ def flush_on_signals?
71
+ Environment.opted_in?(FLUSH_ON_SIGNALS_ENV)
72
+ end
73
+
74
+ # On by default; disabled only by an explicit opt-out.
75
+ def flush_on_exit?
76
+ !Environment.opted_out?(FLUSH_ON_EXIT_ENV)
77
+ end
78
+
79
+ def create_bootstrap_span(name)
80
+ ::OpenTelemetry.tracer_provider.tracer(BOOTSTRAP_TRACER).in_span(name, kind: :internal) { nil }
81
+ rescue StandardError => e
82
+ Dash0::OpenTelemetry.log_debug("Could not create bootstrap span: #{e.message}")
83
+ end
84
+
85
+ # Installs SIGTERM/SIGINT handlers that flush, then re-raise the signal so
86
+ # the process terminates with the expected disposition. The shutdown runs
87
+ # in a fresh thread because a signal trap runs in a restricted context
88
+ # where acquiring the SDK's mutexes would raise; a thread body escapes it.
89
+ #
90
+ # Signal handlers can only be installed from the main thread; if we are not
91
+ # on it, skip them rather than raise (the at-exit flush still applies).
92
+ def install_signal_handlers
93
+ return unless Thread.current == Thread.main
94
+
95
+ FLUSH_SIGNALS.each do |signal|
96
+ Signal.trap(signal) do
97
+ Thread.new { shutdown }.join
98
+ Signal.trap(signal, 'DEFAULT')
99
+ Process.kill(signal, Process.pid)
100
+ end
101
+ end
102
+ rescue ArgumentError => e
103
+ Dash0::OpenTelemetry.log_debug("Could not install signal handlers: #{e.message}")
104
+ end
105
+
106
+ def install_exit_handler
107
+ at_exit { shutdown }
108
+ end
109
+
110
+ # The configured SDK providers that can be shut down. When a signal's SDK
111
+ # is not installed, `OpenTelemetry` may not define the accessor at all, and
112
+ # API no-op providers do not respond to `shutdown`; both are skipped.
113
+ def providers
114
+ %i[tracer_provider meter_provider logger_provider].filter_map do |name|
115
+ next unless ::OpenTelemetry.respond_to?(name)
116
+
117
+ provider = ::OpenTelemetry.public_send(name)
118
+ provider if provider.respond_to?(:shutdown)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module Dash0
7
+ module OpenTelemetry
8
+ module Resource
9
+ # Resource detector that identifies this distribution via the
10
+ # `telemetry.distro.*` resource attributes.
11
+ #
12
+ # `OpenTelemetry::SDK` must be loaded before this is called.
13
+ module Distribution
14
+ # Value of the `telemetry.distro.name` resource attribute.
15
+ NAME = 'dash0-ruby'
16
+
17
+ module_function
18
+
19
+ def detect
20
+ ::OpenTelemetry::SDK::Resources::Resource.create(
21
+ 'telemetry.distro.name' => NAME,
22
+ 'telemetry.distro.version' => Dash0::OpenTelemetry::VERSION
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module Dash0
7
+ module OpenTelemetry
8
+ module Resource
9
+ # Resource detector that derives `k8s.pod.uid` by parsing cgroup files,
10
+ # gated on an `/etc/hosts` marker that indicates a Kubernetes-managed
11
+ # environment. Ported from the Node.js distribution's
12
+ # `opentelemetry-resource-detector-kubernetes-pod`.
13
+ #
14
+ # `OpenTelemetry::SDK` must be loaded before `detect` is called.
15
+ module KubernetesPod
16
+ ETC_HOSTS_FILE = '/etc/hosts'
17
+ EXPECTED_FIRST_LINE_IN_ETC_HOSTS = '# Kubernetes-managed hosts file'
18
+
19
+ POD_UID_CHARS = 36
20
+ POD_LABEL = 'pod'
21
+ POD_LABEL_MOUNT_PART = '/pods/'
22
+ POD_UID_PART_CHARS = POD_UID_CHARS + POD_LABEL.length # "pod" + 36-char uid
23
+ CONTAINER_ID_CHARS = 64
24
+
25
+ # cgroup v1
26
+ PROC_SELF_MOUNTINFO_FILE = '/proc/self/mountinfo'
27
+ # cgroup v2
28
+ PROC_SELF_CGROUP_FILE = '/proc/self/cgroup'
29
+
30
+ # Matches slice names like "kubepods-pode462ffed_94ce_4806_a52e_d2726f448f15.slice".
31
+ POD_UID_IN_CGROUP_LINE_REGEX =
32
+ /\A[a-z_-]*pod(?<uid>[0-9a-f]{8}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{4}[-_][0-9a-f]{12})\.slice\z/
33
+
34
+ extend self
35
+
36
+ # The detector interface: the only public method.
37
+ def detect
38
+ attributes = {}
39
+ uid = pod_uid
40
+ attributes['k8s.pod.uid'] = uid if uid
41
+ ::OpenTelemetry::SDK::Resources::Resource.create(attributes)
42
+ end
43
+
44
+ private
45
+
46
+ # @return [String, nil] the pod uid, or nil when not on Kubernetes or not
47
+ # determinable.
48
+ def pod_uid
49
+ return nil unless kubernetes?
50
+
51
+ pod_uid_from_cgroup_v1(candidate_lines(read_file(PROC_SELF_MOUNTINFO_FILE), POD_UID_CHARS)) ||
52
+ pod_uid_from_cgroup_v2(candidate_lines(read_file(PROC_SELF_CGROUP_FILE), CONTAINER_ID_CHARS))
53
+ end
54
+
55
+ def kubernetes?
56
+ kubernetes_managed_hosts?(read_file(ETC_HOSTS_FILE))
57
+ end
58
+
59
+ def kubernetes_managed_hosts?(content)
60
+ return false if content.nil? || content.strip.empty?
61
+
62
+ first_line = content.split("\n").first
63
+ !first_line.nil? && first_line.start_with?(EXPECTED_FIRST_LINE_IN_ETC_HOSTS)
64
+ end
65
+
66
+ # cgroup v1: a mountinfo line containing "/pods/" followed by the 36-char uid.
67
+ def pod_uid_from_cgroup_v1(lines)
68
+ mount = lines.find do |line|
69
+ index = line.index(POD_LABEL_MOUNT_PART)
70
+ index&.positive?
71
+ end
72
+ return nil unless mount
73
+
74
+ part_after_pod_label = mount.split(POD_LABEL_MOUNT_PART)[1]
75
+ return nil if part_after_pod_label.nil? || part_after_pod_label.empty?
76
+
77
+ part_after_pod_label[0, POD_UID_CHARS]
78
+ end
79
+
80
+ # cgroup v2: the penultimate "/"-separated segment of a cgroup line, either
81
+ # "pod<uid>" or a "…pod<uid>.slice" name (with "_" normalized to "-").
82
+ def pod_uid_from_cgroup_v2(lines)
83
+ lines.each do |line|
84
+ segments = line.split('/')
85
+ next if segments.length <= 2
86
+
87
+ penultimate = segments[-2]
88
+ uid = pod_uid_from_segment(penultimate)
89
+ return uid if uid
90
+ end
91
+ nil
92
+ end
93
+
94
+ def pod_uid_from_segment(segment)
95
+ if segment.start_with?(POD_LABEL) && segment.length == POD_UID_PART_CHARS
96
+ segment[POD_LABEL.length, POD_UID_CHARS]
97
+ elsif (match = POD_UID_IN_CGROUP_LINE_REGEX.match(segment))
98
+ match[:uid].tr('_', '-')
99
+ else
100
+ # Questionable: this may extract a wrong uid, but mirrors upstream's
101
+ # last-resort behavior.
102
+ segment
103
+ end
104
+ end
105
+
106
+ def candidate_lines(content, minimum_length)
107
+ return [] if content.nil? || content.empty?
108
+
109
+ content.split("\n").map(&:strip).select { |line| line.length > minimum_length }
110
+ end
111
+
112
+ def read_file(filename)
113
+ File.read(filename, encoding: 'utf-8')
114
+ rescue StandardError
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright 2026 Dash0 Inc.
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ module Dash0
7
+ module OpenTelemetry
8
+ module Resource
9
+ # Resource detector that provides a fallback `service.name` when none has
10
+ # been configured, derived from the application's own identity.
11
+ #
12
+ # Because the distribution boots at interpreter startup via `RUBYOPT`, the
13
+ # program name is usually the wrapper the container runs (`bundle`, `puma`,
14
+ # `rackup`), not the application. Sampling that would report every Ruby
15
+ # service under the same meaningless name, so instead we read the app's
16
+ # identity from its project files, best signal first:
17
+ #
18
+ # 1. `config/application.rb` / `config/app.rb` `module <Name>` — Rails, Hanami
19
+ # 2. `config.ru` — the `run <AppClass>` target or an inline app class
20
+ # (modular Sinatra, Roda, plain Rack, single-file Rails)
21
+ # 3. the entry script name, when it is not a known wrapper
22
+ #
23
+ # When none of these yields a name (e.g. a classic Sinatra app run through a
24
+ # wrapper), we return nil and let the SDK's `unknown_service` stand — honest
25
+ # absence beats a wrapper's name.
26
+ #
27
+ # The fallback is skipped when a service name is already configured via
28
+ # `OTEL_SERVICE_NAME` or a `service.name` in `OTEL_RESOURCE_ATTRIBUTES`, or
29
+ # when opted out with `DASH0_AUTOMATIC_SERVICE_NAME=false`.
30
+ #
31
+ # `OpenTelemetry::SDK` must be loaded before `detect` is called.
32
+ module ServiceNameFallback
33
+ AUTOMATIC_SERVICE_NAME_ENV = 'DASH0_AUTOMATIC_SERVICE_NAME'
34
+ SERVICE_NAME_KEY = 'service.name'
35
+
36
+ # Files whose top-level `module <Name>` names the application (Rails, Hanami).
37
+ APP_MODULE_FILES = ['config/application.rb', 'config/app.rb'].freeze
38
+ CONFIG_RU = 'config.ru'
39
+ # Any of these in a directory marks it as a plausible application root.
40
+ PROJECT_MARKERS = (APP_MODULE_FILES + [CONFIG_RU, 'Gemfile']).freeze
41
+ # How far up from the working directory to look for the application root.
42
+ MAX_ROOT_WALK = 4
43
+ # Config files are tiny; cap the read so a pathological file can't stall boot.
44
+ MAX_CONFIG_BYTES = 64 * 1024
45
+
46
+ # Program names that are wrappers/launchers rather than the application, so
47
+ # they carry no service identity and must not be used as a name.
48
+ WRAPPER_EXECUTABLES = %w[
49
+ bundle bundler rake rackup ruby irb spring rails
50
+ puma unicorn thin falcon sidekiq resque foreman
51
+ ].freeze
52
+
53
+ # A `run <X>` / `class < <X>` target whose leading constant is one of these
54
+ # is a framework generic (`run Rails.application`, `run Sinatra::Application`),
55
+ # not the app's own name, so it is not a usable signal.
56
+ FRAMEWORK_GENERICS = %w[Rails Sinatra Hanami Rack].freeze
57
+
58
+ extend self
59
+
60
+ # The detector interface: the only public method.
61
+ def detect
62
+ attributes = {}
63
+ name = fallback_service_name
64
+ attributes[SERVICE_NAME_KEY] = name if name
65
+ ::OpenTelemetry::SDK::Resources::Resource.create(attributes)
66
+ end
67
+
68
+ private
69
+
70
+ # @return [String, nil] the derived service name, or nil when a name is
71
+ # already configured, the fallback is opted out, or none can be derived.
72
+ def fallback_service_name(program_name = $PROGRAM_NAME, root = app_root)
73
+ return nil if service_name_configured?
74
+
75
+ name_from_project(root) || derive_from_program_name(program_name)
76
+ end
77
+
78
+ def name_from_project(root)
79
+ return nil unless root
80
+
81
+ derive_from_app_module(root) || derive_from_config_ru(root)
82
+ end
83
+
84
+ # Rails/Hanami: the top-level `module <Name>` in the app's config file.
85
+ def derive_from_app_module(root)
86
+ APP_MODULE_FILES.each do |relative_path|
87
+ source = read_project_file(root, relative_path)
88
+ next unless source
89
+
90
+ match = source.match(/^module\s+([A-Z]\w*)/)
91
+ return underscore(match[1]) if match
92
+ end
93
+ nil
94
+ end
95
+
96
+ # config.ru: an inline app class (single-file Rails, inline Sinatra) or the
97
+ # `run <AppClass>` target. Framework generics (`Rails.application`,
98
+ # `Sinatra::Application`) carry no identity and are skipped.
99
+ def derive_from_config_ru(root)
100
+ source = read_project_file(root, CONFIG_RU)
101
+ return nil unless source
102
+
103
+ inline = source.match(/^\s*class\s+([A-Z]\w*)\s*<\s*(?:Rails::Application|Sinatra::(?:Base|Application))/)
104
+ return underscore(inline[1]) if inline
105
+
106
+ run_target = source.match(/^\s*run\s+\(?\s*([A-Z]\w*(?:::[A-Z]\w*)*)/)
107
+ if run_target && !FRAMEWORK_GENERICS.include?(run_target[1].split('::').first)
108
+ return underscore(run_target[1])
109
+ end
110
+
111
+ nil
112
+ end
113
+
114
+ def derive_from_program_name(program_name)
115
+ return nil if program_name.nil?
116
+
117
+ name = File.basename(program_name.to_s).delete_suffix('.rb')
118
+ return nil if name.empty? || WRAPPER_EXECUTABLES.include?(name)
119
+
120
+ name
121
+ end
122
+
123
+ # Walks up from the working directory to the nearest ancestor that looks
124
+ # like an application root, or nil if none is found within MAX_ROOT_WALK.
125
+ def app_root(start = Dir.pwd)
126
+ dir = File.expand_path(start)
127
+ MAX_ROOT_WALK.times do
128
+ return dir if PROJECT_MARKERS.any? { |marker| File.readable?(File.join(dir, marker)) }
129
+
130
+ parent = File.dirname(dir)
131
+ break if parent == dir
132
+
133
+ dir = parent
134
+ end
135
+ nil
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ def read_project_file(root, relative_path)
141
+ path = File.join(root, relative_path)
142
+ return nil unless File.readable?(path)
143
+
144
+ File.read(path, MAX_CONFIG_BYTES)
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ # CamelCase / namespaced constant to snake_case: DemoApp -> demo_app,
150
+ # CheckoutAPI -> checkout_api, MyApp::Web -> my_app_web.
151
+ def underscore(name)
152
+ name
153
+ .gsub('::', '_')
154
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
155
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
156
+ .tr('-', '_')
157
+ .downcase
158
+ end
159
+
160
+ def service_name_configured?
161
+ opted_out? || otel_service_name_set? || service_name_in_resource_attributes?
162
+ end
163
+
164
+ def opted_out?
165
+ Environment.opted_out?(AUTOMATIC_SERVICE_NAME_ENV)
166
+ end
167
+
168
+ def otel_service_name_set?
169
+ Environment.present?('OTEL_SERVICE_NAME')
170
+ end
171
+
172
+ # Parses OTEL_RESOURCE_ATTRIBUTES the same way the upstream env detector
173
+ # does, stripping surrounding quotes, to see if a non-empty service.name
174
+ # is present.
175
+ def service_name_in_resource_attributes?
176
+ raw = ENV.fetch('OTEL_RESOURCE_ATTRIBUTES', nil)
177
+ return false if raw.nil? || raw.strip.empty?
178
+
179
+ raw.split(',').any? do |pair|
180
+ key, value = pair.split('=', 2)
181
+ next false unless value && key.strip == SERVICE_NAME_KEY
182
+
183
+ !value.strip.gsub(/\A"|"\z/, '').strip.empty?
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end