diff --git a/.cspell.yml b/.cspell.yml index 6c0db11a8f..652f578124 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -85,7 +85,6 @@ words: - traceparent - traceresponse - tracestate - - linkspector - sarif - bigdecimal diff --git a/.github/labeler.yml b/.github/labeler.yml index d77f7f7338..f88a6c3e00 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -13,6 +13,11 @@ deprecated: - "instrumentation/restclient/**" - "instrumentation/ruby_kafka/**" +auto-instrumentation: + - changed-files: + - any-glob-to-any-file: + - "packages/auto-instrumentation/**" + helpers-mysql: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/ci-contrib.yml b/.github/workflows/ci-contrib.yml index a9f8322036..4684e56f2d 100644 --- a/.github/workflows/ci-contrib.yml +++ b/.github/workflows/ci-contrib.yml @@ -11,6 +11,7 @@ on: - "resources/**" - "processor/**" - "sampler/**" + - "packages/**" - ".github/workflows/ci-contrib.yml" - ".github/actions/**" - "Rakefile" @@ -223,3 +224,32 @@ jobs: with: gem: "opentelemetry-sampler-${{ matrix.gem }}" ruby: "jruby-10.0.2.0" + + auto-instrumentation: + if: ${{ github.repository == 'open-telemetry/opentelemetry-ruby-contrib' }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + name: "auto-instrumentation / ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: "Test Ruby 4.0" + uses: ./.github/actions/test_gem + with: + gem: "opentelemetry-auto-instrumentation" + ruby: "4.0" + - name: "Test Ruby 3.4" + uses: ./.github/actions/test_gem + with: + gem: "opentelemetry-auto-instrumentation" + ruby: "3.4" + - name: "Test Ruby 3.3" + uses: ./.github/actions/test_gem + with: + gem: "opentelemetry-auto-instrumentation" + ruby: "3.3" + yard: true + build: true diff --git a/Dockerfile b/Dockerfile index 4a9c2406da..e5b013533a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,7 @@ ARG PACKAGES="\ tzdata \ util-linux \ imagemagick \ + yaml-dev \ " # Install packages RUN apk update && \ diff --git a/packages/auto-instrumentation/.rubocop.yml b/packages/auto-instrumentation/.rubocop.yml new file mode 100644 index 0000000000..cb254a9080 --- /dev/null +++ b/packages/auto-instrumentation/.rubocop.yml @@ -0,0 +1,8 @@ +inherit_from: ../../.rubocop.yml +Naming/FileName: + Exclude: + - "lib/opentelemetry-auto-instrumentation.rb" + - "test/opentelemetry-auto-instrumentation_test.rb" +Metrics/ModuleLength: + Exclude: + - "lib/opentelemetry-auto-instrumentation.rb" diff --git a/packages/auto-instrumentation/CHANGELOG.md b/packages/auto-instrumentation/CHANGELOG.md new file mode 100644 index 0000000000..d068b6d9e8 --- /dev/null +++ b/packages/auto-instrumentation/CHANGELOG.md @@ -0,0 +1 @@ +# Release History: opentelemetry-auto-instrumentation diff --git a/packages/auto-instrumentation/Gemfile b/packages/auto-instrumentation/Gemfile new file mode 100644 index 0000000000..4be4b3d4d3 --- /dev/null +++ b/packages/auto-instrumentation/Gemfile @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +source 'https://rubygems.org' + +gemspec + +group :test do + gem 'minitest', '~> 6.0' + gem 'rake', '~> 13.0' + gem 'rubocop', '~> 1.86.0' + gem 'rubocop-performance', '~> 1.26.0' + gem 'simplecov', '~> 0.22.0' + gem 'rubocop-minitest', '~> 0.39.0' + gem 'rubocop-rspec', '~> 3.9.0' + gem 'rubocop-rake', '~> 0.7.1' + gem 'yard', '~> 0.9' + gem 'opentelemetry-test-helpers', '~> 0.8.0' + if RUBY_VERSION >= '3.4' + gem 'base64' + gem 'bigdecimal' + gem 'mutex_m' + end + gem 'logger' if RUBY_VERSION >= '4.0.0' +end diff --git a/packages/auto-instrumentation/LICENSE b/packages/auto-instrumentation/LICENSE new file mode 100644 index 0000000000..1ef7dad2c5 --- /dev/null +++ b/packages/auto-instrumentation/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright The OpenTelemetry Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/auto-instrumentation/README.md b/packages/auto-instrumentation/README.md new file mode 100644 index 0000000000..e70ab7986d --- /dev/null +++ b/packages/auto-instrumentation/README.md @@ -0,0 +1,264 @@ +# OpenTelemetry Auto Instrumentation + +The `opentelemetry-auto-instrumentation` gem provides automatic loading and initialization of the OpenTelemetry Ruby SDK for zero-code instrumentation of your applications. + +## Table of Contents + +- [What is OpenTelemetry?](#what-is-opentelemetry) +- [How does this gem fit in?](#how-does-this-gem-fit-in) +- [Getting Started](#getting-started) +- [Telemetry Signals](#telemetry-signals) + - [Traces](#traces) + - [Metrics](#metrics) + - [Logs](#logs) +- [Usage](#usage) +- [Configuration](#configuration) +- [Troubleshooting](#troubleshooting) +- [Example](#example) +- [How can I get involved?](#how-can-i-get-involved) +- [License](#license) + +## What is OpenTelemetry? + +OpenTelemetry is an open source observability framework that provides a unified API, SDK, and tooling for instrumenting cloud-native applications. It captures distributed traces, metrics, and logs from your application, which can be analyzed using observability backends like Prometheus, Jaeger, and others. + +## How does this gem fit in? + +This gem enables OpenTelemetry instrumentation without modifying your application code. It automatically: + +- Loads the OpenTelemetry SDK (traces, metrics, and logs) +- Initializes instrumentations for detected libraries +- Configures OTLP exporters for all three signals +- Optionally configures resource detectors + +This gem is particularly useful with the [OpenTelemetry Operator][opentelemetry-operator] for Kubernetes environments. + +## Getting Started + +Install the gem: + +```console +gem install opentelemetry-auto-instrumentation +``` + +**Note:** Install via `gem install` rather than adding to your Gemfile, as this gem needs to load before your application starts. + +Then instrument any Ruby application: + +```console +RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +For Rails (which calls `Bundler.require` automatically): + +```console +RUBYOPT="-r opentelemetry-auto-instrumentation" rails server +``` + +For other frameworks (Sinatra, Rackup, etc.) that don't call `Bundler.require` automatically: + +```console +OTEL_RUBY_REQUIRE_BUNDLER=true RUBYOPT="-r opentelemetry-auto-instrumentation" rackup config.ru +``` + +### What gets installed? + +Installing `opentelemetry-auto-instrumentation` automatically includes: + +```console +opentelemetry-sdk +opentelemetry-api +opentelemetry-instrumentation-all +opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-metrics +opentelemetry-exporter-otlp-logs +opentelemetry-helpers-mysql +opentelemetry-helpers-sql-processor +opentelemetry-resource-detector-azure +opentelemetry-resource-detector-container +opentelemetry-resource-detector-aws +``` + +## Telemetry Signals + +By default, this gem sets up **traces, metrics, and logs** and exports all three to an OTLP endpoint (`http://localhost:4318`). Each signal can be configured or disabled independently via standard OpenTelemetry environment variables. + +### Traces + +Traces are enabled by default using the OTLP exporter. See the [opentelemetry-sdk README][otel-sdk-readme] for full configuration options. + +**Disable traces:** + +```console +OTEL_TRACES_EXPORTER=none RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Custom endpoint:** + +```console +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://my-collector:4318/v1/traces \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +### Metrics + +Metrics are enabled by default using the OTLP metrics exporter. See the [opentelemetry-metrics-sdk README][otel-metrics-sdk-readme] for full configuration options. + +**Disable metrics:** + +```console +OTEL_METRICS_EXPORTER=none RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Custom endpoint:** + +```console +OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://my-collector:4318/v1/metrics \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +### Logs + +Logs are enabled by default using the OTLP logs exporter. See the [opentelemetry-logs-sdk README][otel-logs-sdk-readme] for full configuration options. + +**Disable logs:** + +```console +OTEL_LOGS_EXPORTER=none RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Custom endpoint:** + +```console +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://my-collector:4318/v1/logs \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +### Disable all signals except traces + +```console +OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +## Usage + +**Send all signals to a collector with a service name:** + +```console +export OTEL_EXPORTER_OTLP_ENDPOINT="http://my-collector:4318" +export OTEL_SERVICE_NAME="my-service" +RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Enable only specific instrumentations:** + +```console +OTEL_RUBY_ENABLED_INSTRUMENTATIONS="mysql2,redis" \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Disable a specific instrumentation:** + +```console +OTEL_RUBY_INSTRUMENTATION_SINATRA_ENABLED=false \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Preload gems that need to be instrumented but aren't in your Gemfile:** + +```console +RUBYOPT="-r faraday -r opentelemetry-auto-instrumentation" ruby application.rb +``` + +**Using with `bundle exec`** (when the gem is installed outside the bundle): + +```console +RUBYOPT="-r $(gem which opentelemetry-auto-instrumentation)" bundle exec rails server +``` + +## Configuration + +The following environment variables are specific to this gem (not standard OpenTelemetry variables): + +| Environment Variable | Description | Example | +| -------------------- | ----------- | ------- | +| `OTEL_RUBY_REQUIRE_BUNDLER` | Set to `true` to automatically call `Bundler.require` during initialization. Required for frameworks that don't call it automatically (e.g., Sinatra). | `true` | +| `OTEL_RUBY_RESOURCE_DETECTORS` | Comma-separated list of resource detectors. Supported: `container`, `azure`, `aws`. | `container,azure,aws` | +| `OTEL_RUBY_ENABLED_INSTRUMENTATIONS` | Only load specific instrumentations (comma-separated). Omit to load all available. | `redis,mysql2,faraday` | +| `OTEL_RUBY_ADDITIONAL_GEM_PATH` | Custom gem installation path for OpenTelemetry Operator environments. | `/custom/gem/path` | +| `DISALLOWED_LIB_PATH` | Comma-separated list of non-OpenTelemetry helper gems to exclude from additional load-path injection. This filters the internal `ADDITIONAL_LIB_GEM_ALLOWLIST`. Supported values: `googleapis-common-protos-types`, `google-protobuf`. | `google-protobuf` | +| `OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG` | Set to `true` for debug output during initialization. | `true` | + +### Additional Gem Allowlist + +The loader always injects OpenTelemetry gems from `OTEL_RUBY_ADDITIONAL_GEM_PATH` into `$LOAD_PATH`. For non-OpenTelemetry helper dependencies, it uses an internal allowlist: + +- `googleapis-common-protos-types` +- `google-protobuf` + +This internal list is implemented as `ADDITIONAL_LIB_GEM_ALLOWLIST` in [lib/opentelemetry-auto-instrumentation.rb](lib/opentelemetry-auto-instrumentation.rb). Use `DISALLOWED_LIB_PATH` if you need to exclude one or more entries for compatibility reasons. + +### Standard OpenTelemetry Environment Variables + +This gem fully supports all standard OpenTelemetry environment variables for configuring exporters, endpoints, resource attributes, sampling, and other SDK behavior. These variables work alongside the gem-specific variables listed above. + +Examples of standard variables you can use: + +- `OTEL_EXPORTER_OTLP_ENDPOINT` — OTLP receiver endpoint (default: `http://localhost:4318`) +- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` — traces-specific endpoint +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` — metrics-specific endpoint +- `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` — logs-specific endpoint +- `OTEL_SERVICE_NAME` — service name resource attribute +- `OTEL_RESOURCE_ATTRIBUTES` — comma-separated key=value pairs for resource attributes +- `OTEL_TRACES_EXPORTER` — trace exporter (default: `otlp`; use `console` or `none`) +- `OTEL_METRICS_EXPORTER` — metrics exporter (default: `otlp`; use `console` or `none`) +- `OTEL_LOGS_EXPORTER` — logs exporter (default: `otlp`; use `console` or `none`) +- `OTEL_TRACES_SAMPLER` — sampling strategy (e.g., `always_on`, `always_off`, `traceidratio`) + +For a complete list of standard variables, refer to the SDK READMEs: + +- [opentelemetry-sdk (traces)][otel-sdk-readme] +- [opentelemetry-metrics-sdk (metrics)][otel-metrics-sdk-readme] +- [opentelemetry-logs-sdk (logs)][otel-logs-sdk-readme] + +## Troubleshooting + +### How Auto-Instrumentation Works + +The gem patches `Bundler::Runtime#require` to inject OpenTelemetry initialization when gems are loaded. Rails calls `Bundler.require` automatically during boot; other frameworks need `OTEL_RUBY_REQUIRE_BUNDLER=true`. + +### Instrumentation Timing Issues + +Instrumentation is only applied to libraries loaded through `Bundler.require`. If you require a library after `Bundler.require` has already been called, it won't be instrumented. Preload it via `RUBYOPT` instead: + +```console +RUBYOPT="-r faraday -r opentelemetry-auto-instrumentation" ruby application.rb +``` + +### Dependency Version Conflicts + +This gem loads OpenTelemetry components and allowlisted helper dependencies (for example `google-protobuf` and `googleapis-common-protos-types`) directly into `$LOAD_PATH`. If your Gemfile pins different versions of these gems, you may encounter conflicts. Remove them from your Gemfile and let this gem manage them, or use `DISALLOWED_LIB_PATH` to exclude specific helper dependencies. + +## Example + +See [example/README.md](example/README.md) + +## How can I get involved? + +The `opentelemetry-auto-instrumentation` gem source is on GitHub, along with related gems. + +The OpenTelemetry Ruby gems are maintained by the OpenTelemetry Ruby special interest group (SIG). You can get involved by joining us on our [GitHub Discussions][discussions-url], [Slack Channel][slack-channel] or attending our weekly meeting. See the [meeting calendar][community-meetings] for dates and times. For more information on this and other language SIGs, see the OpenTelemetry [community page][ruby-sig]. + +## License + +The `opentelemetry-auto-instrumentation` gem is distributed under the Apache 2.0 license. See LICENSE for more information. + +[ruby-sig]: https://github.com/open-telemetry/community#ruby-sig +[community-meetings]: https://github.com/open-telemetry/community#community-meetings +[slack-channel]: https://cloud-native.slack.com/archives/C01NWKKMKMY +[discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions +[opentelemetry-operator]: https://github.com/open-telemetry/opentelemetry-operator +[otel-sdk-readme]: https://github.com/open-telemetry/opentelemetry-ruby/tree/main/sdk +[otel-metrics-sdk-readme]: https://github.com/open-telemetry/opentelemetry-ruby/tree/main/metrics_sdk +[otel-logs-sdk-readme]: https://github.com/open-telemetry/opentelemetry-ruby/tree/main/logs_sdk diff --git a/packages/auto-instrumentation/Rakefile b/packages/auto-instrumentation/Rakefile new file mode 100644 index 0000000000..1a64ba842e --- /dev/null +++ b/packages/auto-instrumentation/Rakefile @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'bundler/gem_tasks' +require 'rake/testtask' +require 'yard' +require 'rubocop/rake_task' + +RuboCop::RakeTask.new + +Rake::TestTask.new :test do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/**/*_test.rb'] +end + +YARD::Rake::YardocTask.new do |t| + t.stats_options = ['--list-undoc'] +end + +if RUBY_ENGINE == 'truffleruby' + task default: %i[test] +else + task default: %i[test rubocop yard] +end diff --git a/packages/auto-instrumentation/example/README.md b/packages/auto-instrumentation/example/README.md new file mode 100644 index 0000000000..d3bc0f09a3 --- /dev/null +++ b/packages/auto-instrumentation/example/README.md @@ -0,0 +1,116 @@ +# Example + +## Installation + +First, install the `opentelemetry-auto-instrumentation` gem using `gem install` (not through Bundler): + +```bash +gem install opentelemetry-auto-instrumentation +``` + +This gem should be installed outside your Gemfile so that it can be loaded globally through the `RUBYOPT` environment variable. + +## Simple Example (simple-example) + +A basic Ruby application that demonstrates opentelemetry-auto-instrumentation. + +```bash +bundle install +OTEL_RUBY_REQUIRE_BUNDLER=true OTEL_TRACES_EXPORTER=console RUBYOPT="-r opentelemetry-auto-instrumentation" ruby app.rb +``` + +To also export metrics and logs to the console: + +```bash +bundle install +OTEL_RUBY_REQUIRE_BUNDLER=true \ + OTEL_TRACES_EXPORTER=console \ + OTEL_METRICS_EXPORTER=console \ + OTEL_LOGS_EXPORTER=console \ + RUBYOPT="-r opentelemetry-auto-instrumentation" ruby app.rb +``` + +**What's happening:** + +- `OTEL_RUBY_REQUIRE_BUNDLER=true` tells the gem to call `Bundler.require` during initialization +- `OTEL_TRACES_EXPORTER=console` outputs trace data to the console for visibility +- `OTEL_METRICS_EXPORTER=console` outputs metrics data to the console +- `OTEL_LOGS_EXPORTER=console` outputs log records to the console +- `RUBYOPT` ensures `opentelemetry-auto-instrumentation` is loaded before your application code + +## Rails Example (rails-example) + +A Rails application demonstrating opentelemetry-auto-instrumentation integration. + +### Without opentelemetry-auto-instrumentation + +```bash +bundle exec rackup config.ru +``` + +### With opentelemetry-auto-instrumentation + +```bash +bundle install +OTEL_RUBY_REQUIRE_BUNDLER=false OTEL_TRACES_EXPORTER=console RUBYOPT="-r opentelemetry-auto-instrumentation" bundle exec rackup config.ru +``` + +To also export metrics and logs: + +```bash +bundle install +OTEL_RUBY_REQUIRE_BUNDLER=false \ + OTEL_TRACES_EXPORTER=console \ + OTEL_METRICS_EXPORTER=console \ + OTEL_METRIC_EXPORT_INTERVAL=5000 \ + OTEL_LOGS_EXPORTER=console \ + RUBYOPT="-r opentelemetry-auto-instrumentation" bundle exec rackup config.ru +``` + +To send all signals to an OTLP collector: + +```bash +bundle install +OTEL_RUBY_REQUIRE_BUNDLER=false \ + OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" \ + OTEL_SERVICE_NAME="my-rails-app" \ + RUBYOPT="-r opentelemetry-auto-instrumentation" bundle exec rackup config.ru +``` + +**What's happening:** + +- `OTEL_RUBY_REQUIRE_BUNDLER=false` because Rails automatically calls `Bundler.require` during boot +- The OpenTelemetry gem is loaded first via `RUBYOPT`, then Rails initializes with instrumentation automatically applied +- When no exporter env vars are set, traces, metrics, and logs default to the OTLP exporter sending to `http://localhost:4318` + +### Test the instrumentation + +In another terminal, make a request to generate traces: + +```bash +curl http://localhost:9292 +``` + +You should see trace output in the console where the Rails server is running. + +### Load sequence + +The correct sequence is: + +1. `opentelemetry-auto-instrumentation` is loaded (via `RUBYOPT`) +2. User libraries are required +3. `Bundler.require` is called (by Rails or manually) +4. OpenTelemetry SDK is initialized +5. Instrumentation is installed for loaded libraries + +### Troubleshooting: Default Gem Version Conflicts + +If you encounter an error like "You have already activated [gem] X.X.X, but your Gemfile requires [gem] Y.Y.Y", install the required version explicitly: + +```bash +gem install [gem-name] -v '[version]' +``` + +This occurs because OpenTelemetry is loaded early via `RUBYOPT`, and if any of its dependencies activate a default gem version that differs from your Gemfile, Bundler raises a conflict error. + +This won't cause issues in the operator because only OpenTelemetry-related gems will be included in your Ruby environment. diff --git a/packages/auto-instrumentation/example/rails-example/Gemfile b/packages/auto-instrumentation/example/rails-example/Gemfile new file mode 100644 index 0000000000..e3933128b0 --- /dev/null +++ b/packages/auto-instrumentation/example/rails-example/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Gemfile +source 'https://rubygems.org' + +gem 'rails' +gem 'rack' +gem 'rake' +gem 'webrick' diff --git a/packages/auto-instrumentation/example/rails-example/app.rb b/packages/auto-instrumentation/example/rails-example/app.rb new file mode 100644 index 0000000000..3264032379 --- /dev/null +++ b/packages/auto-instrumentation/example/rails-example/app.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'rails' +require 'action_controller/railtie' + +require 'bundler' +Bundler.require + +# NoOp meter for when OpenTelemetry is not available +class NoOpCounter + def add(value, attributes: {}) + # No-op implementation + end +end + +# NoOp logger for when OpenTelemetry is not available +class NoOpLogger + def on_emit(severity_text: nil, body: nil) + # No-op implementation + end +end + +# MyApp +class MyApp < Rails::Application + config.secret_key_base = 'your_secret_key_here' + config.eager_load = false + config.logger = Logger.new($stdout) + config.api_only = true + config.active_support.to_time_preserves_timezone = :zone + + # Share OpenTelemetry objects across the app through Rails config. + if defined?(OpenTelemetry) + config.x.otel_meter = OpenTelemetry.meter_provider.meter('rails-example') + config.x.otel_request_counter = config.x.otel_meter.create_counter( + 'http.request.count', + description: 'Counts the number of HTTP requests' + ) + config.x.otel_logger = OpenTelemetry.logger_provider.logger(name: 'rails-example') + else + config.x.otel_request_counter = NoOpCounter.new + config.x.otel_logger = NoOpLogger.new + end +end + +# ApplicationController +# rubocop disable:Style/OneClassPerFile +class ApplicationController < ActionController::API + def index + MyApp.config.x.otel_request_counter.add(1, attributes: { 'http.route' => '/' }) + MyApp.config.x.otel_logger.on_emit(severity_text: 'INFO', body: 'Handling request: GET /') + render json: { message: 'Hello World!', time: Time.current } + end + + def hello + MyApp.config.x.otel_request_counter.add(1, attributes: { 'http.route' => '/hello' }) + name = params[:name] || 'World' + MyApp.config.x.otel_logger.on_emit(severity_text: 'INFO', body: "Handling request: GET /hello, name=#{name}") + render json: { greeting: "Hello #{name}!" } + end + + def create + MyApp.config.x.otel_request_counter.add(1, attributes: { 'http.route' => '/data' }) + MyApp.config.x.otel_logger.on_emit(severity_text: 'INFO', body: 'Handling request: POST /data') + render json: { + message: 'Data received', + data: params.except(:controller, :action) + } + end +end +# rubocop enable:Style/OneClassPerFile + +MyApp.initialize! + +MyApp.routes.draw do + get '/', to: 'application#index' + get '/hello', to: 'application#hello' + post '/data', to: 'application#create' +end diff --git a/packages/auto-instrumentation/example/rails-example/config.ru b/packages/auto-instrumentation/example/rails-example/config.ru new file mode 100644 index 0000000000..3ca0d86570 --- /dev/null +++ b/packages/auto-instrumentation/example/rails-example/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative 'app' + +run MyApp diff --git a/packages/auto-instrumentation/example/simple-example/Gemfile b/packages/auto-instrumentation/example/simple-example/Gemfile new file mode 100644 index 0000000000..ebc45888f5 --- /dev/null +++ b/packages/auto-instrumentation/example/simple-example/Gemfile @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' diff --git a/packages/auto-instrumentation/example/simple-example/app.rb b/packages/auto-instrumentation/example/simple-example/app.rb new file mode 100644 index 0000000000..b9dd7bab10 --- /dev/null +++ b/packages/auto-instrumentation/example/simple-example/app.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 +require 'uri' +require 'net/http' + +url = URI.parse('http://catfact.ninja/fact') +req = Net::HTTP::Get.new(url.to_s) +Net::HTTP.start(url.host, url.port) do |http| + http.request(req) +end diff --git a/packages/auto-instrumentation/lib/opentelemetry-auto-instrumentation.rb b/packages/auto-instrumentation/lib/opentelemetry-auto-instrumentation.rb new file mode 100644 index 0000000000..ccd0db005e --- /dev/null +++ b/packages/auto-instrumentation/lib/opentelemetry-auto-instrumentation.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# OTelBundlerPatch +module OTelBundlerPatch + # Nested module to handle OpenTelemetry initialization logic + module OTelInitializer + @_otel_mutex = Mutex.new + @_otel_initialized = false + + def self._otel_registry_instrumentation_classes + registry = ::OpenTelemetry::Instrumentation.registry + + # The registry only exposes lookup/install methods publicly, so enumerate + # the internal collection to derive supported instrumentation names. + registry.instance_variable_get(:@instrumentation) || [] + rescue StandardError + [] + end + + def self._otel_snake_case(value) + value + .gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2') + .gsub(/([a-z\\d])([A-Z])/, '\\1_\\2') + .tr('-', '_') + .downcase + end + + def self._otel_registry_aliases_for(instrumentation_name) + suffix = instrumentation_name.delete_prefix('OpenTelemetry::Instrumentation::') + segment_variants = suffix.split('::').map do |segment| + snake = _otel_snake_case(segment) + compact = segment.downcase + [snake, compact].uniq + end + + segment_variants.reduce(['']) do |aliases, variants| + aliases.flat_map do |alias_prefix| + variants.map { |variant| alias_prefix.empty? ? variant : "#{alias_prefix}_#{variant}" } + end + end + end + + def self._otel_registry_lookup + @_otel_registry_lookup ||= _otel_registry_instrumentation_classes.each_with_object({}) do |instrumentation_class, lookup| + instrumentation_name = instrumentation_class.instance.name + _otel_registry_aliases_for(instrumentation_name).each do |alias_name| + lookup[alias_name] ||= instrumentation_name + end + rescue StandardError + next + end + end + + def self._otel_detect_resource_from_env + resource_map = { + 'container' => (defined?(::OpenTelemetry::Resource::Detector::Container) ? ::OpenTelemetry::Resource::Detector::Container : nil), + 'azure' => (defined?(::OpenTelemetry::Resource::Detector::Azure) ? ::OpenTelemetry::Resource::Detector::Azure : nil), + 'aws' => (defined?(::OpenTelemetry::Resource::Detector::AWS) ? ::OpenTelemetry::Resource::Detector::AWS : nil) + } + + ENV['OTEL_RUBY_RESOURCE_DETECTORS'].to_s.split(',').map(&:strip).reject(&:empty?).reduce(::OpenTelemetry::SDK::Resources::Resource.create({})) do |resource, detector| + detector_class = resource_map[detector] + detector_class ? resource.merge(detector_class.detect) : resource + end + end + + def self._otel_distro_resource + ::OpenTelemetry::SDK::Resources::Resource.create( + { + 'telemetry.distro.name' => 'opentelemetry-ruby-instrumentation', + 'telemetry.distro.version' => '0.0.0' + } + ) + end + + def self._otel_determine_enabled_instrumentation + env = ENV['OTEL_RUBY_ENABLED_INSTRUMENTATIONS'].to_s + + return [] if env.strip.empty? + + instrumentation_lookup = _otel_registry_lookup + + env.split(',').filter_map do |instrumentation| + normalized = instrumentation.strip.downcase + value = instrumentation_lookup[normalized] + warn "[OpenTelemetry] WARNING: Unknown instrumentation '#{instrumentation.strip}'" if value.nil? && ENV['OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG'] == 'true' + value + end + end + + def self._otel_check_for_bundled_otel_gems + bundled_otel_gems = Bundler.definition.dependencies.select do |dep| + dep.name.start_with?('opentelemetry-') + end + + return if bundled_otel_gems.empty? + + gem_names = bundled_otel_gems.map(&:name).sort.join(', ') + warn '[OpenTelemetry] WARNING: Detected OpenTelemetry gems in your Gemfile: ' \ + "#{gem_names}. When using opentelemetry-auto-instrumentation, OpenTelemetry gems are loaded " \ + 'from the opentelemetry-auto-instrumentation gem path, NOT from your bundle. The gem versions ' \ + 'in your Gemfile/Gemfile.lock are not used and may cause version conflicts or ' \ + 'unexpected behavior. Please remove these gems from your Gemfile when using ' \ + 'opentelemetry-auto-instrumentation.' + rescue StandardError => e + warn "[OpenTelemetry] WARNING: Unable to check Gemfile for OpenTelemetry gems: #{e.message}" if ENV['OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG'] == 'true' + end + + def self._otel_require_otel + @_otel_mutex.synchronize do + return if @_otel_initialized + + @_otel_initialized = true + + begin + _otel_check_for_bundled_otel_gems + + required_instrumentation = _otel_determine_enabled_instrumentation + + resource = _otel_detect_resource_from_env + resource = resource.merge(_otel_distro_resource) + + OpenTelemetry::SDK.configure do |c| + c.resource = resource + if required_instrumentation.empty? + c.use_all + else + required_instrumentation.each do |instrumentation| + c.use instrumentation + end + end + end + + OpenTelemetry.logger.info { 'Auto-instrumentation initialized' } + rescue StandardError => e + @_otel_initialized = false + warn "Auto-instrumentation failed to initialize. Error: #{e.message}" + end + end + end + end + + def require(...) + super + OTelInitializer._otel_require_otel + end +end + +require 'bundler' + +ADDITIONAL_LIB_GEM_ALLOWLIST = %w[ + googleapis-common-protos-types + google-protobuf +].freeze + +parse_env_list = lambda do |key| + ENV[key].to_s.split(',').map(&:strip).reject(&:empty?) +end + +# /otel-auto-instrumentation-ruby is default path for otel operator (ruby.go) +# If requires different gem path to load gem, set env OTEL_RUBY_ADDITIONAL_GEM_PATH +gem_path = ENV['OTEL_RUBY_ADDITIONAL_GEM_PATH'] || '/otel-auto-instrumentation-ruby' +gem_path = Gem.dir unless Dir.exist?(gem_path) + +gem_entries = Dir.glob("#{gem_path}/gems/*") + +# googleapis-common-protos-types and google-protobuf are dependencies for otlp exporters +# google-cloud-env are dependencies for gcp resource detectors +otel_lib_path = gem_entries.select do |file_path| + File.basename(file_path).start_with?('opentelemetry-') +end + +disallowed_lib_paths = parse_env_list.call('DISALLOWED_LIB_PATH') +allowed_additional_lib_gems = ADDITIONAL_LIB_GEM_ALLOWLIST - disallowed_lib_paths + +additional_lib_path = gem_entries.select do |file_path| + gem_dir_name = File.basename(file_path) + allowed_additional_lib_gems.any? { |gem_name| gem_dir_name.start_with?("#{gem_name}-") } +end + +# unshift file_path add opentelemetry component at the top of $LOAD_PATH +otel_lib_path.each { |file_path| $LOAD_PATH.unshift("#{file_path}/lib") } +additional_lib_path.each { |file_path| $LOAD_PATH.unshift("#{file_path}/lib") } + +warn "Loading the gem path from #{gem_path}\n$LOAD_PATH after unshift: #{$LOAD_PATH.join(',')}" if ENV['OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG'] == 'true' + +# These are required for the prepend OTelBundlerPatch to fetch OpenTelemetry::SDK.configure +require 'opentelemetry-sdk' +require 'opentelemetry-metrics-sdk' +require 'opentelemetry-logs-sdk' +require 'opentelemetry-exporter-otlp' +require 'opentelemetry-exporter-otlp-metrics' +require 'opentelemetry-exporter-otlp-logs' +require 'opentelemetry-instrumentation-all' + +resource_detectors = parse_env_list.call('OTEL_RUBY_RESOURCE_DETECTORS') +require 'opentelemetry-resource-detector-container' if resource_detectors.include?('container') +require 'opentelemetry-resource-detector-azure' if resource_detectors.include?('azure') +require 'opentelemetry-resource-detector-aws' if resource_detectors.include?('aws') + +Bundler::Runtime.prepend(OTelBundlerPatch) + +Bundler.require if ENV['OTEL_RUBY_REQUIRE_BUNDLER'] == 'true' diff --git a/packages/auto-instrumentation/lib/opentelemetry/auto_instrumentation/version.rb b/packages/auto-instrumentation/lib/opentelemetry/auto_instrumentation/version.rb new file mode 100644 index 0000000000..889a90ebd8 --- /dev/null +++ b/packages/auto-instrumentation/lib/opentelemetry/auto_instrumentation/version.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module AutoInstrumentation + VERSION = '0.0.0' + end +end diff --git a/packages/auto-instrumentation/opentelemetry-auto-instrumentation.gemspec b/packages/auto-instrumentation/opentelemetry-auto-instrumentation.gemspec new file mode 100644 index 0000000000..3f2051d838 --- /dev/null +++ b/packages/auto-instrumentation/opentelemetry-auto-instrumentation.gemspec @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'opentelemetry/auto_instrumentation/version' + +Gem::Specification.new do |spec| + spec.name = 'opentelemetry-auto-instrumentation' + spec.version = OpenTelemetry::AutoInstrumentation::VERSION + spec.authors = ['OpenTelemetry Authors'] + spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] + + spec.summary = 'Auto-instrumentation for OpenTelemetry Ruby' + spec.description = 'Auto-instrumentation for OpenTelemetry Ruby' + spec.homepage = 'https://github.com/open-telemetry/opentelemetry-ruby-contrib' + spec.license = 'Apache-2.0' + + spec.files = Dir.glob('lib/**/*.rb') + + Dir.glob('*.md') + + ['LICENSE'] + + spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 3.3' + + spec.add_dependency 'opentelemetry-api', '~> 1.9.0' + spec.add_dependency 'opentelemetry-exporter-otlp', '~> 0.33.0' + spec.add_dependency 'opentelemetry-exporter-otlp-logs', '~> 0.4.0' + spec.add_dependency 'opentelemetry-exporter-otlp-metrics', '~> 0.8.0' + spec.add_dependency 'opentelemetry-helpers-mysql', '~> 0.5.0' + spec.add_dependency 'opentelemetry-helpers-sql', '~> 0.3.0' + spec.add_dependency 'opentelemetry-helpers-sql-processor', '~> 0.4.0' + spec.add_dependency 'opentelemetry-instrumentation-all', '~> 0.91.0' + spec.add_dependency 'opentelemetry-logs-api', '~> 0.3.0' + spec.add_dependency 'opentelemetry-logs-sdk', '~> 0.5.1' + spec.add_dependency 'opentelemetry-metrics-api', '~> 0.5.0' + spec.add_dependency 'opentelemetry-metrics-sdk', '~> 0.13.1' + spec.add_dependency 'opentelemetry-resource-detector-aws', '~> 0.5.0' + spec.add_dependency 'opentelemetry-resource-detector-azure', '~> 0.3.0' + spec.add_dependency 'opentelemetry-resource-detector-container', '~> 0.3.0' + spec.add_dependency 'opentelemetry-sdk', '~> 1.11.0' + + if spec.respond_to?(:metadata) + spec.metadata['changelog_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}/file/CHANGELOG.md" + spec.metadata['source_code_uri'] = "https://github.com/open-telemetry/opentelemetry-ruby-contrib/tree/#{spec.name}/v#{spec.version}/packages/auto-instrumentation" + spec.metadata['bug_tracker_uri'] = 'https://github.com/open-telemetry/opentelemetry-ruby-contrib/issues' + spec.metadata['documentation_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}" + end +end diff --git a/packages/auto-instrumentation/test/opentelemetry-auto-instrumentation_test.rb b/packages/auto-instrumentation/test/opentelemetry-auto-instrumentation_test.rb new file mode 100644 index 0000000000..5b292298ea --- /dev/null +++ b/packages/auto-instrumentation/test/opentelemetry-auto-instrumentation_test.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe 'OpenTelemetry::AutoInstrumentation' do + let(:auto_instrumentation_path) { File.expand_path('../lib/opentelemetry-auto-instrumentation.rb', __dir__) } + + before do + ENV['OTEL_RUBY_ENABLED_INSTRUMENTATIONS'] = nil + ENV['OTEL_RUBY_INSTRUMENTATION_NET_HTTP_ENABLED'] = nil + ENV['OTEL_RUBY_RESOURCE_DETECTORS'] = nil + ENV['OTEL_RUBY_REQUIRE_BUNDLER'] = nil + ENV['OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG'] = nil + end + + # Verifies that loading the auto-instrumentation gem initialises the TracerProvider + # with the SDK implementation and attaches the expected resource attributes and + # default instrumentation libraries. + it 'simple_load_test' do + result = run_in_subprocess + + _(result[:error]).must_be_nil + _(result[:tracer_provider_class]).must_equal 'OpenTelemetry::SDK::Trace::TracerProvider' + _(result[:resource_attributes]['service.name']).must_equal 'unknown_service' + _(result[:resource_attributes]['telemetry.sdk.name']).must_equal 'opentelemetry' + _(result[:resource_attributes]['telemetry.sdk.language']).must_equal 'ruby' + _(result[:resource_attributes]['telemetry.distro.name']).must_equal 'opentelemetry-ruby-instrumentation' + _(result[:resource_attributes]['telemetry.distro.version']).must_match(/^\d+\.\d+\.\d+/) + _(result[:resource_attributes].key?('container.id')).must_equal false + _(result[:instrumentation_names]).must_include 'OpenTelemetry::Instrumentation::Net::HTTP' + _(result[:instrumentation_names]).must_include 'OpenTelemetry::Instrumentation::Rake' + end + + # Verifies that setting OTEL_RUBY_INSTRUMENTATION__ENABLED=false suppresses + # a specific instrumentation while leaving others active. + it 'simple_load_with_net_http_disabled' do + result = run_in_subprocess('OTEL_RUBY_INSTRUMENTATION_NET_HTTP_ENABLED' => 'false') + + _(result[:error]).must_be_nil + _(result[:instrumentation_names]).must_include 'OpenTelemetry::Instrumentation::Rake' + _(result[:instrumentation_names]).wont_include 'OpenTelemetry::Instrumentation::Net::HTTP' + end + + # Verifies that OTEL_RUBY_ENABLED_INSTRUMENTATIONS restricts initialisation to + # only the listed instrumentation libraries, ignoring all others. + it 'simple_load_with_desired_instrument_only' do + result = run_in_subprocess('OTEL_RUBY_ENABLED_INSTRUMENTATIONS' => 'net_http') + + _(result[:error]).must_be_nil + _(result[:instrumentation_names]).must_include 'OpenTelemetry::Instrumentation::Net::HTTP' + _(result[:instrumentation_names]).wont_include 'OpenTelemetry::Instrumentation::Rake' + end + + describe 'metrics and logs sdk' do + # Verifies that opentelemetry-metrics-sdk is loaded and the global meter_provider + # is replaced with the full SDK implementation rather than the no-op default. + it 'loads_metrics_sdk' do + result = run_in_subprocess + + _(result[:error]).must_be_nil + _(result[:meter_provider_class]).must_equal 'OpenTelemetry::SDK::Metrics::MeterProvider' + end + + # Verifies that opentelemetry-logs-sdk is loaded and the global logger_provider + # is replaced with the full SDK implementation rather than the no-op default. + it 'loads_logs_sdk' do + result = run_in_subprocess + + _(result[:error]).must_be_nil + _(result[:logger_provider_class]).must_equal 'OpenTelemetry::SDK::Logs::LoggerProvider' + end + end + + describe 'signal data' do + # Verifies that the TracerProvider can record a span end-to-end: creates a + # tracer, opens a span with a custom attribute, and confirms the finished span + # is captured by an in-memory exporter with the correct name and attribute. + it 'captures_trace_span' do + result = run_in_subprocess( + { 'OTEL_TRACES_EXPORTER' => 'none', 'OTEL_METRICS_EXPORTER' => 'none', 'OTEL_LOGS_EXPORTER' => 'none' }, + { inspect_signals: true } + ) + + _(result[:error]).must_be_nil + _(result[:spans]).wont_be_empty + span = result[:spans].first + _(span[:name]).must_equal 'test-span' + _(span[:attributes]['test.key']).must_equal 'test.value' + end + + # Verifies that the MeterProvider can record a counter measurement end-to-end: + # creates a counter, adds a value with attributes, pulls the metric reader, and + # confirms the data point is captured with the correct value and attributes. + it 'captures_metric_counter' do + result = run_in_subprocess( + { 'OTEL_TRACES_EXPORTER' => 'none', 'OTEL_METRICS_EXPORTER' => 'none', 'OTEL_LOGS_EXPORTER' => 'none' }, + { inspect_signals: true } + ) + + _(result[:error]).must_be_nil + _(result[:metrics]).wont_be_empty + metric = result[:metrics].find { |m| m[:name] == 'test.counter' } + _(metric).wont_be_nil + data_point = metric[:data_points].first + _(data_point[:value]).must_equal 3 + _(data_point[:attributes]['env']).must_equal 'test' + end + + # Verifies that the LoggerProvider can emit a log record end-to-end: obtains a + # logger, emits a record with a severity and body, and confirms the record is + # captured by an in-memory exporter with the correct body and severity text. + it 'captures_log_record' do + result = run_in_subprocess( + { 'OTEL_TRACES_EXPORTER' => 'none', 'OTEL_METRICS_EXPORTER' => 'none', 'OTEL_LOGS_EXPORTER' => 'none' }, + { inspect_signals: true } + ) + + _(result[:error]).must_be_nil + _(result[:logs]).wont_be_empty + log = result[:logs].first + _(log[:body]).must_equal 'test log message' + _(log[:severity_text]).must_equal 'INFO' + end + end + + describe 'check_for_bundled_otel_gems' do + # Verifies that no warning is emitted when the user's Gemfile contains only + # non-OpenTelemetry gems, since there is no conflict to report. + it 'emits no warning when there are no opentelemetry gems in the bundle' do + result = run_in_subprocess({}, dep_names: %w[rack faraday]) + + _(result[:error]).must_be_nil + _(result[:warning_output]).must_be_empty + end + + # Verifies that a warning naming the conflicting gems is emitted when the + # Gemfile contains OpenTelemetry gems, and that non-OTel gems are not mentioned. + it 'emits a warning listing detected opentelemetry gems' do + otel_gems = %w[opentelemetry-sdk opentelemetry-instrumentation-net_http rack] + result = run_in_subprocess({}, dep_names: otel_gems) + + _(result[:error]).must_be_nil + _(result[:warning_output]).must_include '[OpenTelemetry] WARNING' + _(result[:warning_output]).must_include 'opentelemetry-instrumentation-net_http' + _(result[:warning_output]).must_include 'opentelemetry-sdk' + _(result[:warning_output]).wont_include 'rack' + end + + # Verifies that a Bundler error during the gem-list check is silently swallowed + # when debug mode is off, so the application still starts cleanly. + it 'emits no warning when Bundler.definition raises and debug mode is off' do + result = run_in_subprocess({}, raise_error: true) + + _(result[:error]).must_be_nil + _(result[:warning_output]).must_be_empty + end + + # Verifies that the same Bundler error is surfaced as a warning when debug mode + # is enabled, including the original error message for easier diagnosis. + it 'emits a warning when Bundler.definition raises and debug mode is on' do + result = run_in_subprocess({ 'OTEL_RUBY_AUTO_INSTRUMENTATION_DEBUG' => 'true' }, raise_error: true) + + _(result[:error]).must_be_nil + _(result[:warning_output]).must_include '[OpenTelemetry] WARNING: Unable to check Gemfile' + _(result[:warning_output]).must_include 'simulated bundler error' + end + end +end diff --git a/packages/auto-instrumentation/test/test_helper.rb b/packages/auto-instrumentation/test/test_helper.rb new file mode 100644 index 0000000000..7006ba54a8 --- /dev/null +++ b/packages/auto-instrumentation/test/test_helper.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'simplecov' +require 'rake' +require 'minitest' +require 'minitest/autorun' +require 'net/http' + +# Runs the auto-instrumentation loading logic inside a forked subprocess so that +# each test starts from a clean Ruby process with no previously loaded constants, +# initialized providers, or mutated global state. +# +# Three mutually-exclusive execution branches exist to cover distinct scenarios: +# +# Branch 1 – Bundler warning simulation (dep_names: or raise_error: opts) +# Simulates what happens when the user's Gemfile contains OpenTelemetry gems, or +# when Bundler itself raises during the gem-list check. The subprocess stubs +# Bundler.definition so no real bundle resolution occurs, then calls +# _otel_check_for_bundled_otel_gems directly and captures any stderr warnings. +# +# Branch 2 – Signal data inspection (inspect_signals: true opt) +# Simulates a fully initialised SDK where real telemetry data is produced and +# collected in-memory. Registers in-memory exporters for all three signals +# (traces, metrics, logs), exercises each signal, and returns the captured data +# so tests can assert on specific span names, counter values, and log bodies. +# +# Branch 3 – Default provider class verification (no special opts) +# Simulates normal application startup. Loads the auto-instrumentation and +# calls Bundler.require, then returns provider class names, resource attributes, +# and the list of installed instrumentation so tests can assert the SDK wired +# up the correct implementation classes. +def run_in_subprocess(env_vars = {}, opts = {}) + dep_names = opts[:dep_names] + raise_error = opts.fetch(:raise_error, false) + + read_pipe, write_pipe = IO.pipe + + pid = fork do + SimpleCov.command_name "subprocess-#{Process.pid}" + read_pipe.close + env_vars.each { |key, value| ENV[key] = value } + ENV['OTEL_RUBY_REQUIRE_BUNDLER'] = 'false' + + begin + load auto_instrumentation_path + + require 'stringio' + stderr_capture = StringIO.new + old_stderr = $stderr + $stderr = stderr_capture + + result = {} + + # Branch 1: Bundler warning simulation + if !dep_names.nil? || raise_error + fake_dep = Struct.new(:name) + fake_deps = (dep_names || []).map { |n| fake_dep.new(n) } + + if raise_error + # Suppress the Ruby "method redefined" warning that define_singleton_method + # produces when overwriting Bundler's existing :definition method. + old_verbose = $VERBOSE + $VERBOSE = nil + Bundler.define_singleton_method(:definition) { raise StandardError, 'simulated bundler error' } + else + fake_definition = Object.new + fake_definition.define_singleton_method(:dependencies) { fake_deps } + old_verbose = $VERBOSE + $VERBOSE = nil + Bundler.define_singleton_method(:definition) { fake_definition } + end + $VERBOSE = old_verbose + + OTelBundlerPatch::OTelInitializer._otel_check_for_bundled_otel_gems + # Branch 2: Signal data inspection + # it attaches in-memory exporters to the already-configured providers + # and exercises them with real data. + elsif opts[:inspect_signals] + Bundler.require + + # --- Traces --- + span_exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(span_exporter) + OpenTelemetry.tracer_provider.add_span_processor(span_processor) + tracer = OpenTelemetry.tracer_provider.tracer('test-tracer') + tracer.in_span('test-span') { |span| span.set_attribute('test.key', 'test.value') } + result[:spans] = span_exporter.finished_spans.map do |s| + { name: s.name, attributes: s.attributes.to_h } + end + + # --- Metrics --- + metric_exporter = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new + OpenTelemetry.meter_provider.add_metric_reader(metric_exporter) + meter = OpenTelemetry.meter_provider.meter('test-meter') + counter = meter.create_counter('test.counter', unit: '1', description: 'Test counter') + counter.add(3, attributes: { 'env' => 'test' }) + metric_exporter.pull + result[:metrics] = metric_exporter.metric_snapshots.map do |m| + { + name: m.name, + data_points: m.data_points.map { |dp| { value: dp.value, attributes: dp.attributes.to_h } } + } + end + + # --- Logs --- + log_exporter = OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new + log_processor = OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(log_exporter) + OpenTelemetry.logger_provider.add_log_record_processor(log_processor) + otel_logger = OpenTelemetry.logger_provider.logger(name: 'test-logger') + otel_logger.on_emit(severity_text: 'INFO', body: 'test log message') + result[:logs] = log_exporter.emitted_log_records.map do |lr| + { body: lr.body, severity_text: lr.severity_text } + end + # Branch 3: Default provider class verification + else + Bundler.require + + tracer_provider = OpenTelemetry.tracer_provider + resource = tracer_provider.instance_variable_get(:@resource) + resource_attributes = resource.instance_variable_get(:@attributes) + registry = tracer_provider.instance_variable_get(:@registry) + instrumentation_names = registry.map { |entry| entry.first.name } + + result.merge!( + tracer_provider_class: tracer_provider.class.name, + meter_provider_class: OpenTelemetry.meter_provider.class.name, + logger_provider_class: OpenTelemetry.logger_provider.class.name, + resource_attributes: resource_attributes, + instrumentation_names: instrumentation_names + ) + end + + $stderr = old_stderr + result[:warning_output] = stderr_capture.string + write_pipe.write(Marshal.dump(result)) + rescue StandardError => e + $stderr = old_stderr if defined?(old_stderr) + error_result = Marshal.dump({ error: e.message, backtrace: e.backtrace, warning_output: defined?(stderr_capture) ? stderr_capture.string : '' }) + write_pipe.write(error_result) + ensure + $stderr = old_stderr if defined?(old_stderr) + write_pipe.close + + # Store the simplecov result for this process + SimpleCov::ResultMerger.store_result(SimpleCov::Result.new(Coverage.result)) if SimpleCov.running + exit!(0) + end + end + + write_pipe.close + result_data = read_pipe.read + read_pipe.close + Process.wait(pid) + + # rubocop:disable Security/MarshalLoad + Marshal.load(result_data) + # rubocop:enable Security/MarshalLoad +end