From 7b331e2e05f0227ccd7255f4681464b8c8bd3994 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 30 Sep 2026 10:42:55 +0530 Subject: [PATCH 1/4] feat(otlp-exporter): add OTLP export docs, examples, and endpoint path fix - Expand README.md with a full OTLP export section covering quick start, env vars, YAML config, TLS/mTLS, and troubleshooting - Add examples/README.md with prerequisites and run instructions - Add otlp_http.rb and otlp_with_headers.rb runnable example scripts - Annotate otel.rb header to cross-reference new OTLP examples - Fix resolve_otlp_endpoint to append /v1/traces to user-supplied base URLs (OTEL_EXPORTER_OTLP_ENDPOINT) that have no path - Add two unit tests covering base URL path-appending and fully-qualified URL pass-through Signed-off-by: Arjun Rajappa --- README.md | 173 ++++++++++++++++++ examples/README.md | 137 ++++++++++++++ examples/otel.rb | 15 +- examples/otlp_http.rb | 119 ++++++++++++ examples/otlp_with_headers.rb | 125 +++++++++++++ .../backend/host_agent_reporting_observer.rb | 29 ++- .../host_agent_reporting_observer_test.rb | 42 +++++ 7 files changed, 627 insertions(+), 13 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/otlp_http.rb create mode 100644 examples/otlp_with_headers.rb diff --git a/README.md b/README.md index 825d55f0..13ffd2a2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,179 @@ Although the gem has no configuration required for out of the box metrics and tr This Ruby gem provides a simple API for tracing and also supports [OpenTracing](http://opentracing.io/). See the [Ruby Tracing SDK](https://www.ibm.com/docs/en/instana-observability/current?topic=ruby-tracing-sdk) and [OpenTracing](https://www.ibm.com/docs/en/instana-observability/current?topic=ruby-opentracing) pages for details. +## OTLP Export + +The `instana` gem supports exporting traces via the **OpenTelemetry Protocol (OTLP)** +directly to the Instana agent's OTLP receiver, bypassing the native agent trace format. +OTLP export is **opt-in** — the gem behaves identically to previous versions when +`INSTANA_TRACING_OTLP_ENABLED` is not set. + +### Overview + +When OTLP export is enabled: +- Spans are converted from Instana's internal format to OTLP protobuf +- The converted spans are sent via HTTP/protobuf to the agent's OTLP port (`4318`) +- The endpoint is auto-derived from the same host where the Instana agent was discovered +- The native agent reporting path (`/com.instana.plugin.ruby/traces`) is replaced by OTLP + +### Quick Start + +The only required change is setting one environment variable before starting your application: + +```bash +INSTANA_TRACING_OTLP_ENABLED=true bundle exec rails server +``` + +Expected log output (with `INSTANA_LOG_LEVEL=debug`): +``` +[Instana] Using OTLP Exporter to export result code: 0 +``` + +Result code `0` = `SUCCESS`. No other configuration is needed — the endpoint is +automatically set to `http://:4318/v1/traces`. + +### Gem Dependencies + +`opentelemetry-exporter-otlp` is a **runtime dependency** of the `instana` gem and +is installed automatically. No additional gems are required. + +> **Note:** gRPC (`OTEL_EXPORTER_OTLP_PROTOCOL=grpc`) is not supported. +> Only `http/protobuf` is supported and tested. + +### Environment Variables + +| Variable | Type | Default | Description | +|---|---|---|---| +| `INSTANA_TRACING_OTLP_ENABLED` | Boolean | `false` | Enables OTLP export. Valid values: `true`, `1`, `yes` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | String (URL) | auto-derived from agent host | **Base** endpoint URL. `/v1/traces` is appended automatically. | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | String (URL) | `/v1/traces` | **Fully-qualified** trace endpoint. Used as-is; overrides `OTEL_EXPORTER_OTLP_ENDPOINT`. | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | String | `http/protobuf` | Transport protocol. Only `http/protobuf` is supported. | +| `OTEL_EXPORTER_OTLP_HEADERS` | String | — | Comma-separated `key=value` auth headers. e.g. `api-key=secret,x-tenant-id=myorg` | +| `OTEL_EXPORTER_OTLP_TIMEOUT` | Integer (ms) | `10000` | Maximum time to wait for export completion. | +| `OTEL_EXPORTER_OTLP_COMPRESSION` | String | — | Compression algorithm. Valid value: `gzip`. | +| `OTEL_EXPORTER_OTLP_CERTIFICATE` | String (path) | — | Path to CA certificate file for TLS verification. | +| `OTEL_EXPORTER_OTLP_CLIENT_KEY` | String (path) | — | Path to client private key file for mutual TLS. | +| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` | String (path) | — | Path to client certificate file for mutual TLS. | +| `OTEL_EXPORTER_OTLP_INSECURE` | Boolean | `false` | Disable TLS certificate verification. | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | String | `stable` | Semantic convention stability level. Valid values: `stable`, `development`. | + +> **Endpoint precedence:** `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` > `OTEL_EXPORTER_OTLP_ENDPOINT` > auto-derived from agent host + +### YAML Configuration + +When `INSTANA_CONFIG_PATH` points to a YAML file, OTLP settings are placed under +the `tracing.otlp` key. YAML configuration takes precedence over environment variables. + +**Minimal example** (`config/instana.yml`): +```yaml +tracing: + otlp: + enabled: true +``` + +**Full example:** +```yaml +tracing: + otlp: + enabled: true + endpoint: "http://localhost:4318/v1/traces" + protocol: "http/protobuf" + timeout: 10000 + compression: "gzip" + headers: + x-instana-key: "YOUR_API_KEY" + x-tenant-id: "YOUR_TENANT" + certificate: "/path/to/ca-cert.pem" + client_key: "/path/to/client-key.pem" + client_certificate: "/path/to/client-cert.pem" + insecure: false + semconv_stability: "stable" +``` + +Start the application: +```bash +INSTANA_CONFIG_PATH=config/instana.yml bundle exec rails server +``` + +### Configuration Precedence + +Settings are resolved in this order (highest wins): + +``` +INSTANA_CONFIG_PATH YAML → Environment variables → Agent config → Defaults +``` + +This means a YAML file always wins over env vars. Agent-provided config (pushed +via the Instana agent's discovery payload) is applied after env vars but can be +overridden by YAML or env vars set at startup. + +### TLS / mTLS + +**Server-side TLS** (verify the collector's certificate): +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +bundle exec rails server +``` + +**Mutual TLS** (client certificate authentication): +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +OTEL_EXPORTER_OTLP_CLIENT_KEY=/path/to/client-key.pem \ +OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE=/path/to/client-cert.pem \ +bundle exec rails server +``` + +### Troubleshooting + +**OTLP export is not happening — spans go via the native path** + +OTLP is disabled by default. Check that `INSTANA_TRACING_OTLP_ENABLED=true` is set +in the process environment before the gem initialises (i.e. before `require 'instana'` +or Rails boot). Verify with: +```bash +INSTANA_LOG_LEVEL=debug bundle exec rails server +# Look for: "Using OTLP Exporter" vs "Using Instana Native Exporter" +``` + +**`Failed to initialize OTLP exporter` in the log** + +The exporter could not be constructed. Common causes: +- `OTEL_EXPORTER_OTLP_CERTIFICATE` path does not exist or is not readable +- `OTEL_EXPORTER_OTLP_CLIENT_KEY` / `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` path invalid +- An unsupported protocol (e.g. `grpc`) was set — only `http/protobuf` is supported + +The gem falls back to `@otlp_exporter = nil` and logs an `ERROR` line with the +exception message. Check that message for the root cause. + +**`result code: 1` (FAILURE) — spans not arriving** + +The exporter initialised but the export request failed. Common causes: +- The Instana agent is not running or OTLP is not enabled on the agent side +- `OTEL_EXPORTER_OTLP_ENDPOINT` points to a wrong host or port +- `OTEL_EXPORTER_OTLP_ENDPOINT` was set to a base URL like `http://host:4318` + without `/v1/traces` — this was a known bug fixed in v2.9.0 (the sensor now + appends the path automatically) +- A firewall is blocking port `4318` + +Verify the agent OTLP port is reachable: +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:4318/v1/traces \ + -H "Content-Type: application/x-protobuf" +# 200 or 415 = port open; "connection refused" = agent OTLP not enabled +``` + +**`HTTP request path is empty` exception (pre-v2.9.0)** + +If you are on a version earlier than 2.9.0 and pass a base URL to +`OTEL_EXPORTER_OTLP_ENDPOINT` (without `/v1/traces`), the `opentelemetry-exporter-otlp` +gem crashes with `HTTP request path is empty`. Upgrade to v2.9.0 or later, or use +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` with the full path instead. + ## Documentation You can find more documentation covering supported components and minimum versions in the Instana [documentation portal](https://www.ibm.com/docs/en/instana-observability/current?topic=technologies-monitoring-ruby). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..56b639c5 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,137 @@ +# Instana Ruby Sensor — Examples + +This directory contains runnable example scripts for the `instana` gem. + +## Files + +| File | What it demonstrates | +|---|---| +| [`otel.rb`](otel.rb) | Tracing API: `in_span`, `start_span`, nested spans, error recording, tags | +| [`tracing.rb`](tracing.rb) | Legacy tracing API examples | +| [`otlp_http.rb`](otlp_http.rb) | OTLP export via HTTP/protobuf — minimal quickstart | +| [`otlp_with_headers.rb`](otlp_with_headers.rb) | OTLP export with authentication headers and TLS/mTLS | + +--- + +## Prerequisites + +- Ruby 3.2 or newer (tested on Ruby 4.0.0) +- `instana` gem installed (or Gemfile pointing to a local checkout) +- **Instana agent running locally** with OTLP enabled on port `4318` + +Verify the agent is reachable before running OTLP examples: + +```bash +curl -s http://localhost:42699/ +# Should return a JSON response + +curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:4318/v1/traces \ + -H "Content-Type: application/x-protobuf" +# Expect 200 or 415 — not "connection refused" +``` + +--- + +## Running the examples + +### Tracing API (`otel.rb`) + +```bash +bundle exec ruby examples/otel.rb +``` + +No environment variables needed — uses the native agent reporting path. + +--- + +### OTLP HTTP/protobuf quickstart (`otlp_http.rb`) + +Sends 5 spans (basic, HTTP exit, database, nested, error) to the Instana +agent via OTLP HTTP/protobuf. The endpoint is auto-derived from the agent +host — no explicit endpoint configuration required. + +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +bundle exec ruby examples/otlp_http.rb +``` + +**Optional — explicit endpoint:** +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \ +bundle exec ruby examples/otlp_http.rb +``` + +> **Note on `OTEL_EXPORTER_OTLP_ENDPOINT` vs `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`:** +> - `OTEL_EXPORTER_OTLP_ENDPOINT` — base URL only (e.g. `http://localhost:4318`). +> The sensor appends `/v1/traces` automatically per the OTLP spec. +> - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` — fully-qualified URL including path. +> Used as-is; takes precedence over `OTEL_EXPORTER_OTLP_ENDPOINT`. + +**Expected log output:** +``` +Using OTLP Exporter to export result code: 0 +``` + +**Result code meanings:** +- `0` — `SUCCESS` — spans delivered +- `1` — `FAILURE` — connection error or endpoint unreachable +- `2` — `DROPPED` — batch rejected by the receiver + +--- + +### OTLP with headers and TLS (`otlp_with_headers.rb`) + +#### Headers only (no TLS) + +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_HEADERS="x-instana-key=YOUR_API_KEY,x-tenant-id=YOUR_TENANT" \ +bundle exec ruby examples/otlp_with_headers.rb +``` + +Header format: comma-separated `key=value` pairs, no spaces around `=` or `,`. + +#### TLS — server certificate verification + +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +bundle exec ruby examples/otlp_with_headers.rb +``` + +#### Mutual TLS (mTLS) + +```bash +INSTANA_TRACING_OTLP_ENABLED=true \ +OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +OTEL_EXPORTER_OTLP_CLIENT_KEY=/path/to/client-key.pem \ +OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE=/path/to/client-cert.pem \ +bundle exec ruby examples/otlp_with_headers.rb +``` + +#### Equivalent YAML config + +```bash +cat > /tmp/instana_otlp.yml << 'EOF' +tracing: + otlp: + enabled: true + endpoint: "https://your-collector:4318/v1/traces" + headers: + x-instana-key: "YOUR_API_KEY" + x-tenant-id: "YOUR_TENANT" + certificate: "/path/to/ca-cert.pem" + client_key: "/path/to/client-key.pem" + client_certificate: "/path/to/client-cert.pem" +EOF + +INSTANA_CONFIG_PATH=/tmp/instana_otlp.yml \ +bundle exec ruby examples/otlp_with_headers.rb +``` + +> **Note:** gRPC (`OTEL_EXPORTER_OTLP_PROTOCOL=grpc`) is not supported. +> Only `http/protobuf` is supported. diff --git a/examples/otel.rb b/examples/otel.rb index ab93a587..6f589ffd 100644 --- a/examples/otel.rb +++ b/examples/otel.rb @@ -1,8 +1,17 @@ -# Simple Instana Tracing Examples -# =========================== +# Simple Instana Tracing Examples — Tracing API +# ============================================== # (c) Copyright IBM Corp. 2025 +# This file demonstrates the Instana tracing API (in_span / start_span). +# It covers how to create spans, nest them, record errors, and set tags. +# +# For OTLP-specific setup (enabling export to the Instana agent via +# HTTP/protobuf), see: +# - examples/otlp_http.rb — HTTP/protobuf export (quickstart) +# - examples/otlp_with_headers.rb — authentication headers and TLS/mTLS +# - examples/README.md — prerequisites and how to run + ####################################### ## in_span Method Examples ####################################### @@ -94,5 +103,3 @@ def finish_work(span) child.finish parent.finish - -# Made with Bob diff --git a/examples/otlp_http.rb b/examples/otlp_http.rb new file mode 100644 index 00000000..3cffb5b5 --- /dev/null +++ b/examples/otlp_http.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2025 + +# OTLP HTTP/protobuf Export Example +# ================================== +# +# Demonstrates enabling Instana OTLP export using the HTTP/protobuf protocol. +# Spans are sent directly to the Instana agent's OTLP receiver on port 4318, +# bypassing the native agent trace format. +# +# Prerequisites: +# - Instana agent running locally with OTLP enabled on port 4318 +# - instana gem pointing to this repo (or installed) +# +# Run: +# INSTANA_TRACING_OTLP_ENABLED=true \ +# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ +# ruby examples/otlp_http.rb +# +# Optional — explicit endpoint (agent auto-discovers if omitted): +# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces +# +# Expected output: +# [Instana] Using OTLP Exporter to export result code: 0 + +require 'instana' + +# --------------------------------------------------------------------------- +# Wait for the Instana agent to be ready before creating spans. +# +# Spans created while the agent is not ready are silently dropped — +# `Instana.tracer.start_span` returns a non-recording span when +# `Instana.agent.ready?` is false (discovery has not yet completed). +# +# In long-running servers (Rails, Rack) this is never an issue because +# the agent background thread finishes well before the first request +# arrives. In short-lived scripts the agent must be ready first. +# --------------------------------------------------------------------------- +deadline = Time.now + 15 +sleep(0.1) until Instana.agent.ready? || Time.now > deadline +abort('[otlp_http] Agent not ready after 15 s — is the Instana agent running?') unless Instana.agent.ready? + +puts "[otlp_http] Agent ready — sending 5 spans via OTLP" + +# --------------------------------------------------------------------------- +# 1. Basic span — minimal tracing, just a name +# --------------------------------------------------------------------------- +Instana.tracer.in_span('otlp.example.basic') do + # Simulate work + sleep(0.01) +end + +# --------------------------------------------------------------------------- +# 2. HTTP exit span — outbound HTTP call with standard attributes +# +# Uses SpanKind::CLIENT (kind 2) so the OTLP converter maps this to an +# http.client span with url.full, http.request.method, http.response.status_code. +# --------------------------------------------------------------------------- +Instana.tracer.in_span( + 'GET', + attributes: { + 'http.method' => 'GET', + 'http.url' => 'https://api.example.com/users/42', + 'http.status_code' => 200 + }, + kind: Instana::Trace::SpanKind::CLIENT +) do + # Actual Net::HTTP call would go here + sleep(0.01) +end + +# --------------------------------------------------------------------------- +# 3. Database span — SQL query with sanitised statement +# +# Matches the activerecord / sequel span type. The database converter maps +# db.statement, db.system, and server.address to OTLP semantic conventions. +# --------------------------------------------------------------------------- +Instana.tracer.in_span( + 'activerecord', + attributes: { + 'activerecord.sql' => 'SELECT "users".* FROM "users" WHERE "users"."id" = ?', + 'activerecord.adapter' => 'PostgreSQL' + } +) do + sleep(0.01) +end + +# --------------------------------------------------------------------------- +# 4. Nested spans — parent / child relationship +# +# The child span captures the parent_span_id from SpanContext, which the +# OTLP exporter serialises into the parent_span_id field of the protobuf. +# --------------------------------------------------------------------------- +Instana.tracer.in_span('otlp.example.parent') do + Instana.tracer.in_span('otlp.example.child') do + sleep(0.01) + end +end + +# --------------------------------------------------------------------------- +# 5. Error span — exception recorded as an OTLP span event +# +# When ec (error count) > 0, BaseConverter#build_error_events generates an +# "exception" span event with exception.message and exception.stacktrace. +# The span status is set to ERROR. +# --------------------------------------------------------------------------- +begin + Instana.tracer.in_span('otlp.example.error') do + raise StandardError, 'Something went wrong in the OTLP example' + end +rescue StandardError + # Error is captured inside the span; rescue here to allow script to continue +end + +# Allow the background timer to flush the span batch to the agent +sleep(10) + +puts 'otlp_http.rb complete — check agent/Instana UI for 5 spans' diff --git a/examples/otlp_with_headers.rb b/examples/otlp_with_headers.rb new file mode 100644 index 00000000..91031189 --- /dev/null +++ b/examples/otlp_with_headers.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# (c) Copyright IBM Corp. 2025 + +# OTLP Authenticated Export Example — Headers and TLS/mTLS +# ========================================================= +# +# Demonstrates configuring authentication headers and TLS certificates +# for OTLP export. Use this when your OTLP endpoint requires an API key, +# tenant header, or mutual TLS client certificates. +# +# Prerequisites: +# - Instana agent (or OTel Collector) running with TLS/auth enabled +# - instana gem pointing to this repo (or installed) +# +# --- +# Run with headers only (no TLS): +# +# INSTANA_TRACING_OTLP_ENABLED=true \ +# OTEL_EXPORTER_OTLP_HEADERS="x-instana-key=YOUR_API_KEY,x-tenant-id=YOUR_TENANT" \ +# ruby examples/otlp_with_headers.rb +# +# --- +# Run with TLS (server certificate verification): +# +# INSTANA_TRACING_OTLP_ENABLED=true \ +# OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +# OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +# ruby examples/otlp_with_headers.rb +# +# --- +# Run with mutual TLS (mTLS — client cert + key): +# +# INSTANA_TRACING_OTLP_ENABLED=true \ +# OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector:4318/v1/traces \ +# OTEL_EXPORTER_OTLP_CERTIFICATE=/path/to/ca-cert.pem \ +# OTEL_EXPORTER_OTLP_CLIENT_KEY=/path/to/client-key.pem \ +# OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE=/path/to/client-cert.pem \ +# ruby examples/otlp_with_headers.rb +# +# --- +# Equivalent YAML config (INSTANA_CONFIG_PATH=/path/to/config.yml): +# +# tracing: +# otlp: +# enabled: true +# endpoint: "https://your-collector:4318/v1/traces" +# headers: +# x-instana-key: "YOUR_API_KEY" +# x-tenant-id: "YOUR_TENANT" +# certificate: "/path/to/ca-cert.pem" +# client_key: "/path/to/client-key.pem" +# client_certificate: "/path/to/client-cert.pem" +# +# --- +# Header format for OTEL_EXPORTER_OTLP_HEADERS: +# Comma-separated key=value pairs (no spaces around = or ,) +# e.g. "api-key=secret123,x-tenant-id=myorg,x-custom=value" + +require 'instana' + +# --------------------------------------------------------------------------- +# Wait for the Instana agent to be ready before creating spans. +# See otlp_http.rb for a full explanation of why this is needed in scripts. +# --------------------------------------------------------------------------- +deadline = Time.now + 15 +sleep(0.1) until Instana.agent.ready? || Time.now > deadline +abort('[otlp_with_headers] Agent not ready after 15 s — is the Instana agent running?') unless Instana.agent.ready? + +puts "[otlp_with_headers] Agent ready — sending 5 spans via OTLP" + +# --------------------------------------------------------------------------- +# Emit a representative set of spans to exercise the export path. +# The headers and certificates are set via env vars above; the span +# content here is identical to otlp_http.rb for easy comparison. +# --------------------------------------------------------------------------- + +# 1. Basic span +Instana.tracer.in_span('otlp.auth.basic') do + sleep(0.01) +end + +# 2. HTTP exit span +Instana.tracer.in_span( + 'GET', + attributes: { + 'http.method' => 'GET', + 'http.url' => 'https://api.example.com/profile', + 'http.status_code' => 200 + }, + kind: Instana::Trace::SpanKind::CLIENT +) do + sleep(0.01) +end + +# 3. Database span +Instana.tracer.in_span( + 'activerecord', + attributes: { + 'activerecord.sql' => 'SELECT "profiles".* FROM "profiles" WHERE "profiles"."id" = ?', + 'activerecord.adapter' => 'PostgreSQL' + } +) do + sleep(0.01) +end + +# 4. Nested spans +Instana.tracer.in_span('otlp.auth.parent') do + Instana.tracer.in_span('otlp.auth.child') do + sleep(0.01) + end +end + +# 5. Error span +begin + Instana.tracer.in_span('otlp.auth.error') do + raise StandardError, 'Auth example error' + end +rescue StandardError + # captured inside span +end + +sleep(2) + +puts 'otlp_with_headers.rb complete — check agent/Instana UI for 5 spans' diff --git a/lib/instana/backend/host_agent_reporting_observer.rb b/lib/instana/backend/host_agent_reporting_observer.rb index d1e4026e..4b63cb09 100644 --- a/lib/instana/backend/host_agent_reporting_observer.rb +++ b/lib/instana/backend/host_agent_reporting_observer.rb @@ -199,21 +199,32 @@ def initialize_otlp_exporter @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 + # Returns a fully-qualified OTLP traces endpoint. + # + # When config_source is 'default', builds the endpoint from the discovered + # agent host so OTLP traffic goes to the same host as metrics/traces. + # + # For user-supplied endpoints, appends '/v1/traces' when the URL has no + # path, because OTEL_EXPORTER_OTLP_ENDPOINT is a base URL and the + # opentelemetry-exporter-otlp gem only auto-appends the path when no + # endpoint is given at all. def resolve_otlp_endpoint(endpoint, config_source) - return endpoint unless config_source == 'default' + if config_source == 'default' + agent_host = @client&.host + return endpoint unless agent_host - # 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 + return "http://#{agent_host}:#{OTLP_DEFAULT_PORT}#{OTLP_TRACES_PATH}" + end - "http://#{agent_host}:#{OTLP_DEFAULT_PORT}#{OTLP_TRACES_PATH}" + # User-supplied: append the traces path if no path was given. + uri = URI.parse(endpoint) + uri.path = OTLP_TRACES_PATH if uri.path.empty? || uri.path == '/' + uri.to_s + rescue URI::InvalidURIError + endpoint end end end diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index ddb3133e..4a0e4834 100644 --- a/test/backend/host_agent_reporting_observer_test.rb +++ b/test/backend/host_agent_reporting_observer_test.rb @@ -506,6 +506,48 @@ def test_otlp_exporter_omits_cert_keys_when_nil refute received_opts.key?(:client_key_file), 'client_key_file should not be set when nil' end + # OTEL_EXPORTER_OTLP_ENDPOINT is a base URL — the sensor must append + # '/v1/traces' before passing it to the exporter. + def test_otlp_exporter_appends_traces_path_to_base_endpoint + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, endpoint: 'http://127.0.0.1:4318', config_source: 'env') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal 'http://127.0.0.1:4318/v1/traces', received_opts[:endpoint], + 'Base URL without path must have /v1/traces appended' + end + + # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is already a fully-qualified URL + # and must be passed to the exporter unchanged. + def test_otlp_exporter_preserves_fully_qualified_traces_endpoint + client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + discovery = Concurrent::Atom.new(nil) + received_opts = nil + + with_otlp_config(enabled: true, endpoint: 'http://127.0.0.1:4318/v1/traces', config_source: 'env') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal 'http://127.0.0.1:4318/v1/traces', received_opts[:endpoint], + 'Fully-qualified URL must be passed through unchanged' + end + def test_otlp_export_enabled_exports_spans stub_request(:post, "http://10.10.10.10:9292/com.instana.plugin.ruby.1234") .to_return(status: 200) From d22b4609f1cda77fc5099882e24d5a3322a27583 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 30 Sep 2026 11:16:37 +0530 Subject: [PATCH 2/4] chore(sonar): exclude examples from duplications and coverage analysis Update SonarQube exclusion patterns to recursively match files under .github, bin, examples, and extras directories, and exclude examples from duplicate code detection (CPD) Signed-off-by: Arjun Rajappa --- sonar-project.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 01961ca3..58fea52b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,7 +5,8 @@ sonar.sources=lib sonar.tests=test sonar.test.inclusions=test/**/* sonar.ruby.coverage.reportPaths=coverage/coverage.json -sonar.coverage.exclusions=bin/*,examples/*,extras/*,lib/instana/version.rb +sonar.coverage.exclusions=.github/**/*,bin/**/*,examples/**/*,extras/**/*,lib/instana/version.rb +sonar.cpd.exclusions=examples/**/* sonar.links.homepage=https://github.com/instana/ruby-sensor/ sonar.links.ci=https://circleci.com/gh/instana/ruby-sensor sonar.links.issue=https://github.com/instana/ruby-sensor/issues From 45c9be50fe2f59d7e1fe88d53aa58a82eda90b91 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 30 Sep 2026 12:16:01 +0530 Subject: [PATCH 3/4] feat(otlp-exporter): improve test coverage Signed-off-by: Arjun Rajappa --- .../host_agent_reporting_observer_test.rb | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/test/backend/host_agent_reporting_observer_test.rb b/test/backend/host_agent_reporting_observer_test.rb index 4a0e4834..39c98bf0 100644 --- a/test/backend/host_agent_reporting_observer_test.rb +++ b/test/backend/host_agent_reporting_observer_test.rb @@ -508,11 +508,14 @@ def test_otlp_exporter_omits_cert_keys_when_nil # OTEL_EXPORTER_OTLP_ENDPOINT is a base URL — the sensor must append # '/v1/traces' before passing it to the exporter. - def test_otlp_exporter_appends_traces_path_to_base_endpoint + # Also covers the rescue URI::InvalidURIError branch: an unparseable + # endpoint must be passed through to the exporter unchanged. + def test_otlp_exporter_resolves_user_supplied_endpoint client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) discovery = Concurrent::Atom.new(nil) received_opts = nil + # Base URL — path must be appended with_otlp_config(enabled: true, endpoint: 'http://127.0.0.1:4318', config_source: 'env') do capture = lambda { |**opts| received_opts = opts @@ -525,15 +528,33 @@ def test_otlp_exporter_appends_traces_path_to_base_endpoint assert_equal 'http://127.0.0.1:4318/v1/traces', received_opts[:endpoint], 'Base URL without path must have /v1/traces appended' + + # Invalid URI — must be returned as-is (exercises rescue URI::InvalidURIError) + invalid_endpoint = "not a valid\turi %%" + with_otlp_config(enabled: true, endpoint: invalid_endpoint, config_source: 'env') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(client, discovery, timer_class: MockTimer) + end + end + + assert_equal invalid_endpoint, received_opts[:endpoint], + 'Invalid URI must be passed through unchanged' end # OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is already a fully-qualified URL # and must be passed to the exporter unchanged. + # Also covers the config_source == 'default' + nil agent_host early-return: + # when the client has no host yet, the configured endpoint is used as-is. def test_otlp_exporter_preserves_fully_qualified_traces_endpoint client = Instana::Backend::RequestClient.new('10.10.10.10', 9292) discovery = Concurrent::Atom.new(nil) received_opts = nil + # Fully-qualified user-supplied URL — must pass through unchanged with_otlp_config(enabled: true, endpoint: 'http://127.0.0.1:4318/v1/traces', config_source: 'env') do capture = lambda { |**opts| received_opts = opts @@ -546,6 +567,23 @@ def test_otlp_exporter_preserves_fully_qualified_traces_endpoint assert_equal 'http://127.0.0.1:4318/v1/traces', received_opts[:endpoint], 'Fully-qualified URL must be passed through unchanged' + + # config_source 'default' with no agent host — exercises `return endpoint unless agent_host` + clientless = Instana::Backend::RequestClient.new('10.10.10.10', 9292) + clientless.define_singleton_method(:host) { nil } + fallback_endpoint = 'http://fallback:4318/v1/traces' + with_otlp_config(enabled: true, endpoint: fallback_endpoint, config_source: 'default') do + capture = lambda { |**opts| + received_opts = opts + Minitest::Mock.new + } + OpenTelemetry::Exporter::OTLP::Exporter.stub(:new, capture) do + Instana::Backend::HostAgentReportingObserver.new(clientless, discovery, timer_class: MockTimer) + end + end + + assert_equal fallback_endpoint, received_opts[:endpoint], + 'Endpoint must be returned unchanged when agent host is not yet known' end def test_otlp_export_enabled_exports_spans From a1af701ee6b17ac67d0b64d46590b3825ae1f923 Mon Sep 17 00:00:00 2001 From: Arjun Rajappa Date: Wed, 30 Sep 2026 13:15:00 +0530 Subject: [PATCH 4/4] feat(otlp-exporter): update copyright year to 2026 Signed-off-by: Arjun Rajappa --- examples/otlp_http.rb | 2 +- examples/otlp_with_headers.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/otlp_http.rb b/examples/otlp_http.rb index 3cffb5b5..8db6ae16 100644 --- a/examples/otlp_http.rb +++ b/examples/otlp_http.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# (c) Copyright IBM Corp. 2025 +# (c) Copyright IBM Corp. 2026 # OTLP HTTP/protobuf Export Example # ================================== diff --git a/examples/otlp_with_headers.rb b/examples/otlp_with_headers.rb index 91031189..44e34ff8 100644 --- a/examples/otlp_with_headers.rb +++ b/examples/otlp_with_headers.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# (c) Copyright IBM Corp. 2025 +# (c) Copyright IBM Corp. 2026 # OTLP Authenticated Export Example — Headers and TLS/mTLS # =========================================================