Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e7b6127
feat(otlp-exporter): add span factory base structure
arjun-rajappa May 21, 2026
40a5679
feat(otlp-exporter): fix failing tests
arjun-rajappa May 21, 2026
4e20b6f
fix(resque): failing tests due to warning
arjun-rajappa May 27, 2026
1fd0fcb
lint(rubocop): fix linting failures
arjun-rajappa May 27, 2026
e0e162c
feat(otlp-exporter): return structs instead of ruby objects
arjun-rajappa Jun 4, 2026
179852c
feat(otlp-exporter): include all http spans
arjun-rajappa Jun 4, 2026
ad13438
feat(otl-exporter): alter http_convertor to adapt to base_convertor s…
arjun-rajappa Jun 4, 2026
25d68f3
feat(otlp-exporter): add resource related attributes to span
arjun-rajappa Jun 4, 2026
b0b770f
feat(otlp-exporter): adapt other convertors to new structure
arjun-rajappa Jun 4, 2026
013527f
feat(otlp-exporter): fix failing factory tests
arjun-rajappa Jun 4, 2026
890d830
feat(otlp-exporter): fix linting failures
arjun-rajappa Jun 4, 2026
1a09e27
feat(otlp-exporter): fix sonarqube failures
arjun-rajappa Jun 12, 2026
310d239
feat(otlp-exporter): add basic otlp exporter
arjun-rajappa Jun 11, 2026
4aa2f3e
feat(otlp-exporter): add otlp exporter to gemspec
arjun-rajappa Jun 11, 2026
b404d8a
feat(otlp-exporter): fix rubocop errors
arjun-rajappa Jun 11, 2026
eadad5f
feat(otlp-exporter): return span name as string
arjun-rajappa Jun 12, 2026
5782246
feat(otl-exporter): add converters to all instrumentation
arjun-rajappa Jun 22, 2026
c82a91d
feat(otlp-exporter): add missing tests
arjun-rajappa Jun 23, 2026
742a0f1
feat(otlp-exporter): fix rubocop failures
arjun-rajappa Jun 23, 2026
18073cb
feat(otlp-exporter): add convertor to custom sdk
arjun-rajappa Jun 25, 2026
02a064d
feat(otlp-exporter): add config-driven OTLP exporter initialization
arjun-rajappa Jun 25, 2026
ddaa924
feat(otlp-exporter): fix issues reported by sonarqube
arjun-rajappa Jun 25, 2026
329fafd
feat(otlp-exporter): shutdown the exporter if there is discovary failure
arjun-rajappa Jun 25, 2026
25605de
feat(otel-exporter): use agent address from discovery cycle
arjun-rajappa Jun 25, 2026
7b9f87d
feat(otlp-exporter): add error stack trace to base converter
arjun-rajappa Jul 6, 2026
7f70f9d
feat(otlp-exporter): add span_name overrides to all instrumentation c…
arjun-rajappa Jul 6, 2026
5ba98d2
feat(otlp-exporter): address sonarqube failures
arjun-rajappa Jul 7, 2026
f2e9e21
feat(otlp-exporter): add SSL support to otlp exporter
arjun-rajappa Jul 16, 2026
cfdb982
feat(otlp-exporter): map url.query, server.port, network.protocol att…
arjun-rajappa Jul 16, 2026
b9d02b3
feat(otlp-exporter): build composite RabbitMQ destination name (excha…
arjun-rajappa Jul 17, 2026
fc98a99
feat(otlp-exporter): add tests related to headers
arjun-rajappa Jul 17, 2026
e83015f
feat(otlp-exporter): remove unwanted/repeated files
arjun-rajappa Jul 20, 2026
4677f63
feat(otlp-exporter): fix rubocop failures
arjun-rajappa Jul 20, 2026
3425a4f
feat(otlp-exporter): update resource attributes
arjun-rajappa Aug 13, 2026
8d9be0a
feat(otlp-exporter): fix sonarqube failures
arjun-rajappa Aug 13, 2026
80836b2
feat(otlp-exporter): add support for podman specific attributes
arjun-rajappa Aug 13, 2026
3c318f9
feat(otlp-exporter): refactor container extraction logic
arjun-rajappa Aug 13, 2026
462ce07
feat(otlp-exporter): fix issues found in sonarqube scan
arjun-rajappa Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Rake::TestTask.new(:test) do |t|
else
t.test_files = Dir[
'test/*_test.rb',
'test/{agent,trace,backend,snapshot,span_filtering,samplers}/*_test.rb'
'test/{agent,trace,backend,snapshot,span_filtering}/*_test.rb',
'test/exporter/otlp/*_test.rb'
]
end
end
Expand Down
3 changes: 3 additions & 0 deletions instana.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ Gem::Specification.new do |spec|
spec.add_runtime_dependency('csv', '>= 0.1')
spec.add_runtime_dependency('sys-proctable', '>= 1.2.2')
spec.add_runtime_dependency('opentelemetry-api', '~> 1.4')
# TODO: pin the versions of otel gems which are actual implementation
spec.add_runtime_dependency('opentelemetry-common')
spec.add_runtime_dependency('opentelemetry-semantic_conventions')
spec.add_runtime_dependency('opentelemetry-exporter-otlp')
spec.add_runtime_dependency('cgi')
spec.add_runtime_dependency('oj', '>=3.0.11') unless RUBY_PLATFORM =~ /java/i
end
62 changes: 58 additions & 4 deletions lib/instana/backend/host_agent_reporting_observer.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021
require 'opentelemetry/exporter/otlp'
require_relative '../exporter/otlp/converter_factory'

module Instana
module Backend
Expand All @@ -22,7 +24,7 @@ def initialize(client, discovery, logger: ::Instana.logger, timer_class: Concurr
@timer_class = timer_class
@nonce = Time.now
@processor = processor

initialize_otlp_exporter
# Initialize timers with default 1 second interval
@metrics_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_metrics_to_backend }
@traces_timer = @timer_class.new(execution_interval: 1, run_now: true) { report_traces_to_backend }
Expand All @@ -36,6 +38,8 @@ def update(time, _old_version, new_version)
if new_version.nil?
@metrics_timer&.shutdown
@traces_timer&.shutdown
@otlp_exporter&.shutdown
@otlp_exporter = nil
else
# Read poll_rate from discovery payload - it's nested under plugin.ruby.poll_rate
discovery = @discovery.value
Expand Down Expand Up @@ -90,10 +94,22 @@ def report_traces
path = format(TRACES_DATA_URL, discovery['pid'])

@processor.send do |spans|
response = @client.send_request('POST', path, spans)
success = false
if @otlp_exporter
converted_spans = spans.map do |span|
::Instana::Exporter::Otlp::ConverterFactory.create(span).convert
end
result_code = @otlp_exporter.export(converted_spans)
Instana.logger.debug("Using OTLP Exporter to export result code: #{result_code}")
success = result_code == OpenTelemetry::SDK::Trace::Export::SUCCESS
else
response = @client.send_request('POST', path, spans)
Instana.logger.debug("Using Instana Native Exporter to export result code: #{response}")
success = response&.ok?
end

unless response.ok?
@logger.warn("Failed to send `#{spans.count}` spans to `#{path}`. Response: #{response.code} - #{response.body}")
unless success
@logger.warn("Failed to send `#{spans.count}` spans to `#{path}`.")
trigger_rediscovery
break
end
Expand Down Expand Up @@ -159,6 +175,44 @@ def trigger_rediscovery
@discovery.swap { nil }
::Instana.agent.announce
end

def initialize_otlp_exporter
config = ::Instana.config[:otlp]
unless config[:enabled]
@otlp_exporter = nil
return
end

endpoint = resolve_otlp_endpoint(config[:endpoint], config[:config_source])
opts = { endpoint: endpoint, timeout: config[:timeout] / 1000.0 }
opts[:compression] = config[:compression] if config[:compression]
opts[:headers] = config[:headers] if config[:headers]&.any?
opts[:certificate_file] = config[:certificate] if config[:certificate]
opts[:client_certificate_file] = config[:client_certificate] if config[:client_certificate]
opts[:client_key_file] = config[:client_key] if config[:client_key]

@otlp_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**opts)
rescue StandardError => e
@logger.error("Failed to initialize OTLP exporter: #{e.message}")
@otlp_exporter = nil
end

# Derive the OTLP endpoint from the discovered agent host when no explicit
# endpoint has been configured (config_source == 'default').
OTLP_DEFAULT_PORT = 4318
OTLP_TRACES_PATH = '/v1/traces'.freeze

def resolve_otlp_endpoint(endpoint, config_source)
return endpoint unless config_source == 'default'

# Use the host that was discovered by HostAgentLookup (same host the
# metrics/traces client is already talking to) and append the standard
# OTLP HTTP port and traces path.
agent_host = @client&.host
return endpoint unless agent_host

"http://#{agent_host}:#{OTLP_DEFAULT_PORT}#{OTLP_TRACES_PATH}"
end
end
end
end
164 changes: 152 additions & 12 deletions lib/instana/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@

module Instana
class Config
def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'], agent_port: ENV['INSTANA_AGENT_PORT'])

LEGACY_TRACING_KEY = 'com.instana.tracing'.freeze
TRACING_KEY = 'tracing'.freeze

def initialize(logger: ::Instana.logger, agent_host: ENV.fetch('INSTANA_AGENT_HOST', nil), agent_port: ENV.fetch('INSTANA_AGENT_PORT', nil)) # rubocop:disable Metrics/MethodLength
@config = {}
if agent_host
logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{ENV['INSTANA_AGENT_HOST']})"
logger.debug "Using custom agent host location specified in INSTANA_AGENT_HOST (#{agent_host})"
@config[:agent_host] = agent_host
else
@config[:agent_host] = '127.0.0.1'
end
if agent_port
logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{ENV['INSTANA_AGENT_PORT']})"
logger.debug "Using custom agent port specified in INSTANA_AGENT_PORT (#{agent_port})"
@config[:agent_port] = agent_port
else
@config[:agent_port] = 42699
Expand All @@ -30,7 +34,7 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'],
@config[:tracing] = { :enabled => true }

# Enable/disable tracing exit spans as root spans
@config[:allow_exit_as_root] = ENV['INSTANA_ALLOW_EXIT_AS_ROOT'] == '1'
@config[:allow_exit_as_root] = ENV.fetch('INSTANA_ALLOW_EXIT_AS_ROOT', nil) == '1'

# Enable/Disable logging
@config[:logging] = { :enabled => true }
Expand All @@ -49,6 +53,20 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'],
# @config[:back_trace] = { stack_trace_level: nil }
read_span_stack_config

# OTLP exporter configuration (default: disabled)
@config[:otlp] = {
enabled: false,
endpoint: 'http://localhost:4318/v1/traces',
timeout: 10_000,
compression: nil,
headers: {},
certificate: nil,
client_key: nil,
client_certificate: nil,
config_source: 'default'
}
read_otlp_config

# By default, collected SQL will be sanitized to remove potentially sensitive bind params such as:
# > SELECT "blocks".* FROM "blocks" WHERE "blocks"."name" = "Mr. Smith"
#
Expand All @@ -60,7 +78,7 @@ def initialize(logger: ::Instana.logger, agent_host: ENV['INSTANA_AGENT_HOST'],
@config[:sanitize_sql] = true

# W3C Trace Context Support
@config[:w3c_trace_correlation] = ENV['INSTANA_DISABLE_W3C_TRACE_CORRELATION'].nil?
@config[:w3c_trace_correlation] = ENV.fetch('INSTANA_DISABLE_W3C_TRACE_CORRELATION', nil).nil?

@config[:post_fork_proc] = proc { ::Instana.agent.spawn_background_thread }

Expand Down Expand Up @@ -94,7 +112,7 @@ def []=(key, value)
# Priority: Environment variables > YAML file > Agent discovery > Defaults
def read_span_stack_config
# Try environment variables first
if ENV['INSTANA_STACK_TRACE'] || ENV['INSTANA_STACK_TRACE_LENGTH']
if ENV.fetch('INSTANA_STACK_TRACE', nil) || ENV.fetch('INSTANA_STACK_TRACE_LENGTH', nil)
read_span_stack_config_from_env
@config[:back_trace_technologies] = {}
return
Expand Down Expand Up @@ -122,6 +140,8 @@ def read_config_from_agent(discovery)

# Read stack trace configuration from agent if not already set from YAML or env
read_span_stack_config_from_agent(tracing_config) if should_read_from_agent?(:back_trace)
# Read OTLP configuration from agent if not already set from YAML or env
read_otlp_config_from_agent(tracing_config) if should_read_from_agent?(:otlp)
# Read span filtering configuration from agent
::Instana.span_filtering_config&.read_config_from_agent(discovery)
rescue => e
Expand All @@ -143,17 +163,17 @@ def read_span_stack_config_from_agent(tracing_config)
# Read stack trace configuration from YAML file
# Returns hash with :global and :technologies keys or nil if not found
def read_span_stack_config_from_yaml
config_path = ENV['INSTANA_CONFIG_PATH']
config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil)
return nil unless config_path && File.exist?(config_path)

begin
yaml_content = YAML.safe_load(File.read(config_path))

# Support both "tracing" and "com.instana.tracing" as top-level keys
if yaml_content['com.instana.tracing']
::Instana.logger.warn('Please use "tracing" instead of "com.instana.tracing"')
if yaml_content[LEGACY_TRACING_KEY]
::Instana.logger.warn("Please use \"#{TRACING_KEY}\" instead of \"#{LEGACY_TRACING_KEY}\"")
end
tracing_config = yaml_content['tracing'] || yaml_content['com.instana.tracing']
tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY]
return nil unless tracing_config

result = {}
Expand All @@ -178,8 +198,8 @@ def read_span_stack_config_from_yaml
# Read stack trace configuration from environment variables
def read_span_stack_config_from_env
@config[:back_trace] = {
stack_trace_level: ENV['INSTANA_STACK_TRACE'] || 'error',
stack_trace_length: ENV['INSTANA_STACK_TRACE_LENGTH']&.to_i || 30,
stack_trace_level: ENV.fetch('INSTANA_STACK_TRACE', 'error'),
stack_trace_length: ENV.fetch('INSTANA_STACK_TRACE_LENGTH', 30).to_i,
config_source: 'env'
}
end
Expand Down Expand Up @@ -217,8 +237,128 @@ def get_stack_trace_config(technology)
}
end

# Read OTLP configuration from agent discovery
# @param tracing_config [Hash] The tracing configuration from discovery
def read_otlp_config_from_agent(tracing_config)
otlp_config = tracing_config['otlp']
return unless otlp_config.is_a?(Hash)

@config[:otlp][:enabled] = truthy?(otlp_config['enabled']) unless otlp_config['enabled'].nil?
@config[:otlp][:endpoint] = otlp_config['endpoint'] if otlp_config['endpoint']
@config[:otlp][:timeout] = otlp_config['timeout'].to_i if otlp_config['timeout']
@config[:otlp][:compression] = otlp_config['compression'] if otlp_config['compression']
@config[:otlp][:headers] = otlp_config['headers'] if otlp_config['headers'].is_a?(Hash)
@config[:otlp][:certificate] = otlp_config['certificate'] if otlp_config['certificate']
@config[:otlp][:client_key] = otlp_config['client_key'] if otlp_config['client_key']
@config[:otlp][:client_certificate] = otlp_config['client_certificate'] if otlp_config['client_certificate']
@config[:otlp][:config_source] = 'agent'
end

private

# Read OTLP configuration — precedence: YAML > env vars > defaults (agent handled separately)
def read_otlp_config
# Try YAML first
yaml_otlp = parse_otlp_config_from_yaml
if yaml_otlp
@config[:otlp].merge!(yaml_otlp)
@config[:otlp][:config_source] = 'yaml'
return
end

# Try environment variables
env_otlp = parse_otlp_config_from_env
if env_otlp
@config[:otlp].merge!(env_otlp)
@config[:otlp][:config_source] = 'env'
end
# Otherwise leave defaults ('default' config_source), agent can update later
end

# Parse OTLP config from YAML file at INSTANA_CONFIG_PATH under tracing.otlp
# @return [Hash, nil] merged OTLP settings or nil if not found
def parse_otlp_config_from_yaml
config_path = ENV.fetch('INSTANA_CONFIG_PATH', nil)
return nil unless config_path && File.exist?(config_path)

begin
yaml_content = YAML.safe_load(File.read(config_path))
tracing_config = yaml_content[TRACING_KEY] || yaml_content[LEGACY_TRACING_KEY]
return nil unless tracing_config

otlp_yaml = tracing_config['otlp']
return nil unless otlp_yaml.is_a?(Hash)

build_otlp_yaml_result(otlp_yaml)
rescue => e
::Instana.logger.warn("Failed to load OTLP configuration from YAML: #{e.message}")
nil
end
end

def build_otlp_yaml_result(otlp_yaml)
result = {}
result[:enabled] = truthy?(otlp_yaml['enabled']) unless otlp_yaml['enabled'].nil?
result[:endpoint] = otlp_yaml['endpoint'] if otlp_yaml['endpoint']
result[:timeout] = otlp_yaml['timeout'].to_i if otlp_yaml['timeout']
result[:compression] = otlp_yaml['compression'] if otlp_yaml['compression']
result[:headers] = otlp_yaml['headers'] if otlp_yaml['headers'].is_a?(Hash)
result[:certificate] = otlp_yaml['certificate'] if otlp_yaml['certificate']
result[:client_key] = otlp_yaml['client_key'] if otlp_yaml['client_key']
result[:client_certificate] = otlp_yaml['client_certificate'] if otlp_yaml['client_certificate']
result.empty? ? nil : result
end

# Parse OTLP config from environment variables
# @return [Hash, nil] merged OTLP settings or nil if no relevant env vars are set
def parse_otlp_config_from_env
raw = otlp_env_vars
return nil if raw.values.all?(&:nil?)

result = {}
result[:enabled] = truthy?(raw[:enabled_raw]) unless raw[:enabled_raw].nil?
result[:endpoint] = raw[:endpoint] if raw[:endpoint]
result[:timeout] = raw[:timeout_raw].to_i if raw[:timeout_raw]
result[:compression] = raw[:compression] if raw[:compression]
result[:headers] = parse_otlp_headers(raw[:headers_raw]) if raw[:headers_raw]
result[:certificate] = raw[:certificate] if raw[:certificate]
result[:client_key] = raw[:client_key] if raw[:client_key]
result[:client_certificate] = raw[:client_cert] if raw[:client_cert]
result
end

# Collect raw OTLP-related environment variable values into a single hash
# @return [Hash]
def otlp_env_vars
{
enabled_raw: ENV.fetch('INSTANA_TRACING_OTLP_ENABLED', nil),
endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', nil),
timeout_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TIMEOUT', nil),
compression: ENV.fetch('OTEL_EXPORTER_OTLP_COMPRESSION', nil),
headers_raw: ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_HEADERS', nil) || ENV.fetch('OTEL_EXPORTER_OTLP_HEADERS', nil),
certificate: ENV.fetch('OTEL_EXPORTER_OTLP_CERTIFICATE', nil),
client_key: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_KEY', nil),
client_cert: ENV.fetch('OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE', nil)
}
end

# Parse OTEL_EXPORTER_OTLP_HEADERS value (comma-separated key=value pairs) into a Hash
# @param headers_str [String] e.g. "api-key=secret,x-tenant=tenant1"
# @return [Hash]
def parse_otlp_headers(headers_str)
return {} unless headers_str

headers_str.split(',').each_with_object({}) do |pair, hash|
key, value = pair.split('=', 2)
hash[key.strip] = value&.strip if key
end
end

# Normalise a truthy string value to a boolean
def truthy?(value)
%w[true 1 yes].include?(value.to_s.downcase)
end

# Parse global stack trace configuration from a config hash
# @param global_config [Hash] The global configuration hash
# @param config_source [String] The source of the configuration ('yaml', 'agent', etc.)
Expand Down
Loading
Loading