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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +70 -0
- data/CONTRIBUTING.md +57 -0
- data/LICENSE +202 -0
- data/README.md +128 -0
- data/RELEASING.md +53 -0
- data/lib/dash0/opentelemetry/boot.rb +99 -0
- data/lib/dash0/opentelemetry/environment.rb +53 -0
- data/lib/dash0/opentelemetry/instrumentation_installer.rb +143 -0
- data/lib/dash0/opentelemetry/lifecycle.rb +124 -0
- data/lib/dash0/opentelemetry/resource/distribution.rb +28 -0
- data/lib/dash0/opentelemetry/resource/kubernetes_pod.rb +120 -0
- data/lib/dash0/opentelemetry/resource/service_name_fallback.rb +189 -0
- data/lib/dash0/opentelemetry/sdk_configuration.rb +130 -0
- data/lib/dash0/opentelemetry/version.rb +15 -0
- data/lib/dash0/opentelemetry.rb +45 -0
- data/lib/dash0-opentelemetry.rb +43 -0
- metadata +176 -0
|
@@ -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
|