diff --git a/.github/workflows/ci-contrib.yml b/.github/workflows/ci-contrib.yml index 6bbc510cc6..4ff4d97cc4 100644 --- a/.github/workflows/ci-contrib.yml +++ b/.github/workflows/ci-contrib.yml @@ -27,6 +27,7 @@ jobs: - sql - mysql - sql-obfuscation + - sql-processor os: - ubuntu-latest name: "helpers-${{ matrix.gem }} / ${{ matrix.os }}" diff --git a/.github/workflows/ci-instrumentation.yml b/.github/workflows/ci-instrumentation.yml index 69ad810fcc..c57bb45ec8 100644 --- a/.github/workflows/ci-instrumentation.yml +++ b/.github/workflows/ci-instrumentation.yml @@ -50,6 +50,7 @@ jobs: - httpx - koala - lmdb + - logger - net_http - rack - rails diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index be630bf506..a938221d1b 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -24,7 +24,7 @@ jobs: with: persist-credentials: false - - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -43,6 +43,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5 + uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 with: sarif_file: results.sarif diff --git a/.toys/.data/releases.yml b/.toys/.data/releases.yml index 1182c128ac..a90a96eb88 100644 --- a/.toys/.data/releases.yml +++ b/.toys/.data/releases.yml @@ -55,10 +55,23 @@ gems: version_rb_path: lib/opentelemetry/helpers/sql_obfuscation/version.rb version_constant: [OpenTelemetry, Helpers, SqlObfuscation, VERSION] + - name: opentelemetry-helpers-sql-processor + directory: helpers/sql-processor + version_rb_path: lib/opentelemetry/helpers/sql_processor/version.rb + version_constant: [OpenTelemetry, Helpers, SqlProcessor, VERSION] + - name: opentelemetry-instrumentation-grape directory: instrumentation/grape version_constant: [OpenTelemetry, Instrumentation, Grape, VERSION] + - name: opentelemetry-instrumentation-gruf + directory: instrumentation/gruf + version_constant: [OpenTelemetry, Instrumentation, Gruf, VERSION] + + - name: opentelemetry-instrumentation-logger + directory: instrumentation/logger + version_constant: [OpenTelemetry, Instrumentation, Logger, VERSION] + - name: opentelemetry-instrumentation-racecar directory: instrumentation/racecar version_constant: [OpenTelemetry, Instrumentation, Racecar, VERSION] diff --git a/helpers/sql-obfuscation/CHANGELOG.md b/helpers/sql-obfuscation/CHANGELOG.md index 75d1c3a194..4fa997dc04 100644 --- a/helpers/sql-obfuscation/CHANGELOG.md +++ b/helpers/sql-obfuscation/CHANGELOG.md @@ -1,5 +1,13 @@ # Release History: opentelemetry-helpers-sql-obfuscation +### v0.4.0 / 2025-10-08 + +## Deprecation Notice + +* **DEPRECATED:** This gem, `opentelemetry-helpers-sql-obfuscation`, has been replaced by `opentelemetry-helpers-sql-processor`. This is the final release and serves as a transitional package. +* **ACTION REQUIRED:** No action is needed unless you use this gem directly. If you use this gem directly, update your `Gemfile` to use `gem 'opentelemetry-helpers-sql-processor'` instead. +* **SUPPORT ENDING:** `opentelemetry-helpers-sql-obfuscation` will no longer receive updates. + ### v0.3.0 / 2025-01-16 * BREAKING CHANGE: Set minimum supported version to Ruby 3.1 diff --git a/helpers/sql-obfuscation/README.md b/helpers/sql-obfuscation/README.md index de4c436d11..9e9ff223e4 100644 --- a/helpers/sql-obfuscation/README.md +++ b/helpers/sql-obfuscation/README.md @@ -1,4 +1,12 @@ -# OpenTelemetry Instrumentation Helpers: SQL Obfuscation +# Deprecation Notice + +**This gem (`opentelemetry-helpers-sql-obfuscation`) is deprecated and no longer maintained.** + +It has been replaced by **`opentelemetry-helpers-sql-processor`**. + +All future development, bug fixes, and feature releases will occur in the new gem. + +## OpenTelemetry Instrumentation Helpers: SQL Obfuscation This Ruby gem contains logic to obfuscate SQL. It's intended for use by by gem authors instrumenting SQL adapter libraries, such as mysql2, pg, and trilogy. diff --git a/helpers/sql-obfuscation/lib/opentelemetry-helpers-sql-obfuscation.rb b/helpers/sql-obfuscation/lib/opentelemetry-helpers-sql-obfuscation.rb index a839b7e58d..cdbb025bd9 100644 --- a/helpers/sql-obfuscation/lib/opentelemetry-helpers-sql-obfuscation.rb +++ b/helpers/sql-obfuscation/lib/opentelemetry-helpers-sql-obfuscation.rb @@ -5,3 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 require_relative 'opentelemetry/helpers' + +OpenTelemetry.logger.warn <<~WARNING + [DEPRECATION] The 'opentelemetry-helpers-sql-obfuscation' gem has been renamed to 'opentelemetry-helpers-sql-processor'. No action is needed unless you use this gem directly. +WARNING diff --git a/helpers/sql-obfuscation/lib/opentelemetry/helpers/sql_obfuscation/version.rb b/helpers/sql-obfuscation/lib/opentelemetry/helpers/sql_obfuscation/version.rb index a8a1fc54a1..3d90c2db07 100644 --- a/helpers/sql-obfuscation/lib/opentelemetry/helpers/sql_obfuscation/version.rb +++ b/helpers/sql-obfuscation/lib/opentelemetry/helpers/sql_obfuscation/version.rb @@ -7,7 +7,7 @@ module OpenTelemetry module Helpers module SqlObfuscation - VERSION = '0.3.0' + VERSION = '0.4.0' end end end diff --git a/helpers/sql-obfuscation/opentelemetry-helpers-sql-obfuscation.gemspec b/helpers/sql-obfuscation/opentelemetry-helpers-sql-obfuscation.gemspec index 734e507747..3a17a8b369 100644 --- a/helpers/sql-obfuscation/opentelemetry-helpers-sql-obfuscation.gemspec +++ b/helpers/sql-obfuscation/opentelemetry-helpers-sql-obfuscation.gemspec @@ -14,8 +14,8 @@ Gem::Specification.new do |spec| spec.authors = ['OpenTelemetry Authors'] spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] - spec.summary = 'SQL Obfuscation Instrumentation Helpers for the OpenTelemetry framework' - spec.description = 'SQL Obfuscation Instrumentation Helpers for the OpenTelemetry framework' + spec.summary = 'This gem is deprecated and no longer maintained. It has been replaced by opentelemetry-helpers-sql-processor.' + spec.description = 'This gem is deprecated and no longer maintained. It has been replaced by opentelemetry-helpers-sql-processor.' spec.homepage = 'https://github.com/open-telemetry/opentelemetry-ruby-contrib' spec.license = 'Apache-2.0' diff --git a/helpers/sql-processor/.rubocop.yml b/helpers/sql-processor/.rubocop.yml new file mode 100644 index 0000000000..4b31975de1 --- /dev/null +++ b/helpers/sql-processor/.rubocop.yml @@ -0,0 +1,4 @@ +inherit_from: ../../.rubocop.yml + +Gemspec/DevelopmentDependencies: + Enabled: false diff --git a/helpers/sql-processor/.yardopts b/helpers/sql-processor/.yardopts new file mode 100644 index 0000000000..6c38f31551 --- /dev/null +++ b/helpers/sql-processor/.yardopts @@ -0,0 +1,9 @@ +--no-private +--title=OpenTelemetry SQL Processor Instrumentation Helpers +--markup=markdown +--main=README.md +./lib/opentelemetry/helpers/**/*.rb +./lib/opentelemetry/helpers.rb +- +README.md +CHANGELOG.md diff --git a/helpers/sql-processor/CHANGELOG.md b/helpers/sql-processor/CHANGELOG.md new file mode 100644 index 0000000000..2627104a61 --- /dev/null +++ b/helpers/sql-processor/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History: opentelemetry-helpers-sql-processor + +### v0.1.0 / 2025-10-08 + +Initial release. diff --git a/helpers/sql-processor/Gemfile b/helpers/sql-processor/Gemfile new file mode 100644 index 0000000000..f77eaf288e --- /dev/null +++ b/helpers/sql-processor/Gemfile @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +source 'https://rubygems.org' + +gemspec + +group :test do + gem 'bundler', '~> 2.4' + gem 'minitest', '~> 5.0' + gem 'opentelemetry-test-helpers', '~> 0.3' + gem 'rake', '~> 13.0' + gem 'rubocop', '~> 1.79.1' + gem 'rubocop-performance', '~> 1.25.0' + gem 'simplecov', '~> 0.22.0' + gem 'yard', '~> 0.9' + gem 'yard-doctest', '~> 0.1.6' + if RUBY_VERSION >= '3.4' + gem 'base64' + gem 'mutex_m' + end +end diff --git a/helpers/sql-processor/LICENSE b/helpers/sql-processor/LICENSE new file mode 100644 index 0000000000..1ef7dad2c5 --- /dev/null +++ b/helpers/sql-processor/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/helpers/sql-processor/README.md b/helpers/sql-processor/README.md new file mode 100644 index 0000000000..e869796009 --- /dev/null +++ b/helpers/sql-processor/README.md @@ -0,0 +1,65 @@ +# OpenTelemetry Instrumentation Helpers: SQL Processor + +This Ruby gem contains logic to process SQL, including obfuscation. It's intended for use by by gem authors instrumenting SQL adapter libraries, such as mysql2, pg, and trilogy. + +Obfuscation logic is largely drawn from the [New Relic Ruby agent's SQL Obfuscation Helpers module][new-relic-obfuscation-helpers]. + +## Usage + +Add the gem to your instrumentation's gemspec file: + +```ruby +# opentelemetry-instrumentation-your-gem.gemspec + spec.add_dependency 'opentelemetry-helpers-sql-processor' +``` + +Add the gem to your instrumentation's Gemfile: + +```ruby +# Gemfile + +group :test do + gem 'opentelemetry-helpers-sql-processor', path: '../../helpers/sql-processor' +end +``` +## Obfuscation + +Make sure the `Instrumentation` class for your gem contains configuration options for: + +- `:obfuscation_limit`: the length at which the SQL string will not be obfuscated + Example: `option :obfuscation_limit, default: 2000, validate: :integer` + +If you want to add support for a new adapter, update the following constants to include keys for your adapter: + +- `DIALECT_COMPONENTS` +- `CLEANUP_REGEX` + +You must also add a new constant that calls the `generate_regex` method with your adapter's DIALECT_COMPONENTS that is named like `_COMPONENTS_REGEX`, such as: `MYSQL_COMPONENTS_REGEX`. + +Check [New Relic's SQL Obfuscation Helpers module][new-relic-obfuscation-helpers] to see if regular expressions for your adapter already exist. + +### Examples + +To obfuscate sql in your library: + +```ruby +OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: config[:obfuscation_limit], adapter: :postgres) +``` + +## How can I get involved? + +The `opentelemetry-helpers-sql-processor` gem source is [on github][repo-github], along with related gems including `opentelemetry-instrumentation-pg` and `opentelemetry-instrumentation-trilogy`. + +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-helpers-sql-processor` gem is distributed under the Apache 2.0 license. See [LICENSE][license-github] for more information. + +[new-relic-obfuscation-helpers]: https://github.com/newrelic/newrelic-ruby-agent/blob/96e7aca22c1c873c0f5fe704a2b3bb19652db68e/lib/new_relic/agent/database/obfuscation_helpers.rb +[repo-github]: https://github.com/open-telemetry/opentelemetry-ruby +[license-github]: https://github.com/open-telemetry/opentelemetry-ruby-contrib/blob/main/LICENSE +[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 diff --git a/helpers/sql-processor/Rakefile b/helpers/sql-processor/Rakefile new file mode 100644 index 0000000000..1a64ba842e --- /dev/null +++ b/helpers/sql-processor/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/helpers/sql-processor/lib/opentelemetry-helpers-sql-processor.rb b/helpers/sql-processor/lib/opentelemetry-helpers-sql-processor.rb new file mode 100644 index 0000000000..a839b7e58d --- /dev/null +++ b/helpers/sql-processor/lib/opentelemetry-helpers-sql-processor.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative 'opentelemetry/helpers' diff --git a/helpers/sql-processor/lib/opentelemetry/helpers.rb b/helpers/sql-processor/lib/opentelemetry/helpers.rb new file mode 100644 index 0000000000..671d2b6e1a --- /dev/null +++ b/helpers/sql-processor/lib/opentelemetry/helpers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/helpers/sql_obfuscation' + +module OpenTelemetry + # The helpers module contains functionality shared across multiple + # instrumentation libraries + module Helpers + end +end diff --git a/helpers/sql-processor/lib/opentelemetry/helpers/sql_obfuscation.rb b/helpers/sql-processor/lib/opentelemetry/helpers/sql_obfuscation.rb new file mode 100644 index 0000000000..debb2b7e5c --- /dev/null +++ b/helpers/sql-processor/lib/opentelemetry/helpers/sql_obfuscation.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0module OpenTelemetry + +require 'opentelemetry-common' + +module OpenTelemetry + module Helpers + # + # This module contains SQL obfuscation behavior to share with + # instrumentation for specific database adapters. + # The class uses code from: https://github.com/newrelic/newrelic-ruby-agent/blob/1fca78cc7a087421ad58088d8bea72c0362bc62f/lib/new_relic/agent/database/obfuscation_helpers.rb + # + # To use this in your instrumentation, the `Instrumentation` class for + # your gem must contain configuration options for: + # * `:db_statement` + # Example: + # `option :db_statement, default: :include, validate: %I[omit include obfuscate]` + # * `:obfuscation_limit` + # Example: + # `option :obfuscation_limit, default: 2000, validate: :integer` + # + # If you want to add support for a new adapter, update the following + # constants to include keys for your adapter: + # * DIALECT_COMPONENTS + # * CLEANUP_REGEX + # You must also add a new constant that uses `generate_regex` with your + # adapter's dialect components that is named like + # `_COMPONENTS_REGEX`, such as: `MYSQL_COMPONENTS_REGEX`. + # + # @api public + module SqlObfuscation + module_function + + # From: https://github.com/newrelic/newrelic-ruby-agent/blob/1fca78cc7a087421ad58088d8bea72c0362bc62f/lib/new_relic/agent/database/obfuscation_helpers.rb + COMPONENTS_REGEX_MAP = { + single_quotes: /'(?:[^']|'')*?(?:\\'.*|'(?!'))/, + double_quotes: /"(?:[^"]|"")*?(?:\\".*|"(?!"))/, + dollar_quotes: /(\$(?!\d)[^$]*?\$).*?(?:\1|$)/, + uuids: /\{?(?:[0-9a-fA-F]\-*){32}\}?/, + numeric_literals: /-?\b(?:[0-9]+\.)?[0-9]+([eE][+-]?[0-9]+)?\b/, + boolean_literals: /\b(?:true|false|null)\b/i, + hexadecimal_literals: /0x[0-9a-fA-F]+/, + comments: /(?:#|--).*?(?=\r|\n|$)/i, + multi_line_comments: %r{(?:\/\*.*?\*\/)}m, + oracle_quoted_strings: /q'\[.*?(?:\]'|$)|q'\{.*?(?:\}'|$)|q'\<.*?(?:\>'|$)|q'\(.*?(?:\)'|$)/ + }.freeze + + DIALECT_COMPONENTS = { + default: COMPONENTS_REGEX_MAP.keys, + mysql: %i[single_quotes double_quotes numeric_literals boolean_literals + hexadecimal_literals comments multi_line_comments], + postgres: %i[single_quotes dollar_quotes uuids numeric_literals + boolean_literals comments multi_line_comments], + sqlite: %i[single_quotes numeric_literals boolean_literals hexadecimal_literals + comments multi_line_comments], + oracle: %i[single_quotes oracle_quoted_strings numeric_literals comments + multi_line_comments], + cassandra: %i[single_quotes uuids numeric_literals boolean_literals + hexadecimal_literals comments multi_line_comments] + }.freeze + + PLACEHOLDER = '?' + + # We use these to check whether the query contains any quote characters + # after obfuscation. If so, that's a good indication that the original + # query was malformed, and so our obfuscation can't reliably find + # literals. In such a case, we'll replace the entire query with a + # placeholder. + CLEANUP_REGEX = { + default: %r{'|"|\/\*|\*\/}, + mysql: %r{'|"|\/\*|\*\//}, + postgres: %r{'|\/\*|\*\/|\$(?!\?)/}, + sqlite: %r{'|\/\*|\*\//}, + cassandra: %r{'|\/\*|\*\//}, + oracle: %r{'|\/\*|\*\//} + }.freeze + + # @api private + def generate_regex(dialect) + components = DIALECT_COMPONENTS[dialect] + Regexp.union(components.map { |component| COMPONENTS_REGEX_MAP[component] }) + end + + DEFAULT_COMPONENTS_REGEX = generate_regex(:default) + MYSQL_COMPONENTS_REGEX = generate_regex(:mysql) + POSTGRES_COMPONENTS_REGEX = generate_regex(:postgres) + SQLITE_COMPONENTS_REGEX = generate_regex(:sqlite) + CASSANDRA_COMPONENTS_REGEX = generate_regex(:cassandra) + ORACLE_COMPONENTS_REGEX = generate_regex(:oracle) + + # This is a SQL obfuscation utility intended for use in database adapter instrumentation. + # + # @param sql [String] The SQL to obfuscate. + # @param obfuscation_limit [optional Integer] the length at which the SQL string will not be obfuscated + # @param adapter [optional Symbol] the type of database adapter calling the method. `:default`, `:mysql` and `:postgres` are supported. + # @return [String] The SQL query string where the values are replaced with "?". When the sql statement exceeds the obufscation limit + # the first matched pair from the SQL statement will be returned, with an appended truncation message. If trunaction is unsuccessful, + # a string describing the error will be returned. + # + # @api public + def obfuscate_sql(sql, obfuscation_limit: 2000, adapter: :default) + return "SQL not obfuscated, query exceeds #{obfuscation_limit} characters" if sql.size > obfuscation_limit + + regex = case adapter + when :mysql + MYSQL_COMPONENTS_REGEX + when :postgres + POSTGRES_COMPONENTS_REGEX + else + DEFAULT_COMPONENTS_REGEX + end + + # Original MySQL UTF-8 Encoding Fixes: + # https://github.com/open-telemetry/opentelemetry-ruby-contrib/pull/160 + # https://github.com/open-telemetry/opentelemetry-ruby-contrib/pull/345 + sql = OpenTelemetry::Common::Utilities.utf8_encode(sql, binary: true) + + sql = sql.gsub(regex, PLACEHOLDER) + return 'Failed to obfuscate SQL query - quote characters remained after obfuscation' if CLEANUP_REGEX[adapter].match(sql) + + sql + rescue StandardError => e + OpenTelemetry.handle_error(message: 'Failed to obfuscate SQL', exception: e) + end + end + end +end diff --git a/helpers/sql-processor/lib/opentelemetry/helpers/sql_processor/version.rb b/helpers/sql-processor/lib/opentelemetry/helpers/sql_processor/version.rb new file mode 100644 index 0000000000..d54b7c5127 --- /dev/null +++ b/helpers/sql-processor/lib/opentelemetry/helpers/sql_processor/version.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Helpers + module SqlProcessor + VERSION = '0.1.0' + end + end +end diff --git a/helpers/sql-processor/opentelemetry-helpers-sql-processor.gemspec b/helpers/sql-processor/opentelemetry-helpers-sql-processor.gemspec new file mode 100644 index 0000000000..dd3c039a6e --- /dev/null +++ b/helpers/sql-processor/opentelemetry-helpers-sql-processor.gemspec @@ -0,0 +1,38 @@ +# 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/helpers/sql_processor/version' + +Gem::Specification.new do |spec| + spec.name = 'opentelemetry-helpers-sql-processor' + spec.version = OpenTelemetry::Helpers::SqlProcessor::VERSION + spec.authors = ['OpenTelemetry Authors'] + spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] + + spec.summary = 'SQL Processing Instrumentation Helpers for the OpenTelemetry framework' + spec.description = 'SQL Processing Instrumentation Helpers for the OpenTelemetry framework' + 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', '.yardopts'] + spec.require_paths = ['lib'] + spec.required_ruby_version = ">= #{File.read(File.expand_path('../../gemspecs/RUBY_REQUIREMENT', __dir__))}" + + spec.add_dependency 'opentelemetry-common', '~> 0.21' + + 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/main/helpers/sql-processor' + 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 + + spec.post_install_message = File.read(File.expand_path('../../gemspecs/POST_INSTALL_MESSAGE', __dir__)) +end diff --git a/helpers/sql-processor/test/fixtures/sql_obfuscation.json b/helpers/sql-processor/test/fixtures/sql_obfuscation.json new file mode 100644 index 0000000000..9c75b8f168 --- /dev/null +++ b/helpers/sql-processor/test/fixtures/sql_obfuscation.json @@ -0,0 +1,685 @@ +[ + { + "name": "back_quoted_identifiers.mysql", + "obfuscated": [ + "SELECT `t001`.`c2` FROM `t001` WHERE `t001`.`c2` = ? AND c3=? LIMIT ?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT `t001`.`c2` FROM `t001` WHERE `t001`.`c2` = 'value' AND c3=\"othervalue\" LIMIT ?" + }, + { + "name": "comment_delimiters_in_double_quoted_strings", + "obfuscated": [ + "SELECT * FROM t WHERE foo=? AND baz=?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT * FROM t WHERE foo=\"bar/*\" AND baz=\"whatever */qux\"" + }, + { + "name": "comment_delimiters_in_single_quoted_strings", + "obfuscated": [ + "SELECT * FROM t WHERE foo=? AND baz=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM t WHERE foo='bar/*' AND baz='whatever */qux'" + }, + { + "name": "double_quoted_identifiers.postgres", + "obfuscated": [ + "SELECT \"t001\".\"c2\" FROM \"t001\" WHERE \"t001\".\"c2\" = ? AND c3=? LIMIT ?" + ], + "dialects": [ + "postgres" + ], + "sql": "SELECT \"t001\".\"c2\" FROM \"t001\" WHERE \"t001\".\"c2\" = 'value' AND c3=1234 LIMIT 1" + }, + { + "name": "end_of_line_comment_in_double_quoted_string", + "obfuscated": [ + "SELECT * FROM t WHERE foo=? AND\n baz=?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT * FROM t WHERE foo=\"bar--\" AND\n baz=\"qux--\"" + }, + { + "name": "end_of_line_comment_in_single_quoted_string", + "obfuscated": [ + "SELECT * FROM t WHERE foo=? AND\n baz=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM t WHERE foo='bar--' AND\n baz='qux--'" + }, + { + "name": "end_of_query_comment_cstyle", + "obfuscated": [ + "SELECT * FROM foo WHERE bar=? ?", + "SELECT * FROM foo WHERE bar=? " + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM foo WHERE bar='baz' /* Hide Me */" + }, + { + "name": "end_of_query_comment_doubledash", + "obfuscated": [ + "SELECT * FROM foobar WHERE password=?\n?", + "SELECT * FROM foobar WHERE password=?\n" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM foobar WHERE password='secret2'\n-- No peeking!" + }, + { + "name": "end_of_query_comment_hash", + "obfuscated": [ + "SELECT foo, bar FROM baz WHERE password=? ?", + "SELECT foo, bar FROM baz WHERE password=? " + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT foo, bar FROM baz WHERE password='secret2' # Secret" + }, + { + "name": "escape_string_constants.postgres", + "sql": "SELECT \"col1\", \"col2\" from \"table\" WHERE \"col3\"=E'foo\\'bar\\\\baz' AND country=e'foo\\'bar\\\\baz'", + "obfuscated": [ + "SELECT \"col1\", \"col2\" from \"table\" WHERE \"col3\"=E?", + "SELECT \"col1\", \"col2\" from \"table\" WHERE \"col3\"=E? AND country=e?" + ], + "dialects": [ + "postgres" + ], + "comments": [ + "PostgreSQL supports an alternate string quoting mode where backslash escape", + "sequences are interpreted.", + "See: http://www.postgresql.org/docs/9.3/static/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE" + ] + }, + { + "name": "multiple_literal_types.mysql", + "obfuscated": [ + "INSERT INTO `X` values(?,?, ? , ?, ?)" + ], + "dialects": [ + "mysql" + ], + "sql": "INSERT INTO `X` values(\"test\",0, 1 , 2, 'test')" + }, + { + "name": "numbers_in_identifiers", + "obfuscated": [ + "SELECT c11.col1, c22.col2 FROM table c11, table c22 WHERE value=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT c11.col1, c22.col2 FROM table c11, table c22 WHERE value='nothing'" + }, + { + "name": "numeric_literals", + "sql": "INSERT INTO X VALUES(1, 23456, 123.456, 99+100)", + "obfuscated": [ + "INSERT INTO X VALUES(?, ?, ?, ?+?)", + "INSERT INTO X VALUES(?, ?, ?.?, ?+?)" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "string_double_quoted.mysql", + "obfuscated": [ + "SELECT * FROM table WHERE name=? AND value=?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT * FROM table WHERE name=\"foo\" AND value=\"don't\"" + }, + { + "name": "string_single_quoted", + "obfuscated": [ + "SELECT * FROM table WHERE name=? AND value = ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM table WHERE name='foo' AND value = 'bar'" + }, + { + "name": "string_with_backslash_and_twin_single_quotes", + "obfuscated": [ + "SELECT * FROM table WHERE col=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM table WHERE col='foo\\''bar'", + "comments": [ + "If backslashes are being ignored in single-quoted strings", + "(standard_conforming_strings=on in PostgreSQL, or NO_BACKSLASH_ESCAPES is on", + "in MySQL), then this is valid SQL." + ] + }, + { + "name": "string_with_embedded_double_quote", + "obfuscated": [ + "SELECT * FROM table WHERE col1=? AND col2=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM table WHERE col1='foo\"bar' AND col2='what\"ever'" + }, + { + "name": "string_with_embedded_newline", + "obfuscated": [ + "select * from accounts where accounts.name != ? order by accounts.name" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "select * from accounts where accounts.name != 'dude \n newline' order by accounts.name" + }, + { + "name": "string_with_embedded_single_quote.mysql", + "obfuscated": [ + "SELECT * FROM table WHERE col1=? AND col2=?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT * FROM table WHERE col1=\"don't\" AND col2=\"won't\"" + }, + { + "name": "string_with_escaped_quotes.mysql", + "sql": "INSERT INTO X values('', 'jim''s ssn',0, 1 , 'jim''s son''s son', \"\"\"jim''s\"\" hat\", \"\\\"jim''s secret\\\"\")", + "obfuscated": [ + "INSERT INTO X values(?, ?,?, ? , ?, ?, ?", + "INSERT INTO X values(?, ?,?, ? , ?, ?, ?)" + ], + "dialects": [ + "mysql" + ] + }, + { + "name": "string_with_trailing_backslash", + "sql": "SELECT * FROM table WHERE name='foo\\' AND color='blue'", + "obfuscated": [ + "SELECT * FROM table WHERE name=?", + "SELECT * FROM table WHERE name=? AND color=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "comments": [ + "If backslashes are being ignored in single-quoted strings", + "(standard_conforming_strings=on in PostgreSQL, or NO_BACKSLASH_ESCAPES is on", + "in MySQL), then this is valid SQL." + ] + }, + { + "name": "string_with_trailing_escaped_backslash.mysql", + "obfuscated": [ + "SELECT * FROM table WHERE foo=?" + ], + "dialects": [ + "mysql" + ], + "sql": "SELECT * FROM table WHERE foo=\"this string ends with a backslash\\\\\"" + }, + { + "name": "string_with_trailing_escaped_backslash_single_quoted", + "obfuscated": [ + "SELECT * FROM table WHERE foo=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "SELECT * FROM table WHERE foo='this string ends with a backslash\\\\'" + }, + { + "name": "string_with_trailing_escaped_quote", + "sql": "SELECT * FROM table WHERE name='foo\\'' AND color='blue'", + "obfuscated": [ + "SELECT * FROM table WHERE name=?", + "SELECT * FROM table WHERE name=? AND color=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "string_with_twin_single_quotes", + "obfuscated": [ + "INSERT INTO X values(?, ?,?, ? , ?)" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "sql": "INSERT INTO X values('', 'a''b c',0, 1 , 'd''e f''s h')" + }, + { + "name": "end_of_line_comments_with_quotes", + "sql": "SELECT * FROM t WHERE -- '\n bar='baz' -- '", + "obfuscated": [ + "SELECT * FROM t WHERE ?\n bar=? ?", + "SELECT * FROM t WHERE ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "mixed_comments_and_quotes", + "sql": "SELECT * FROM t WHERE /* ' */ \n bar='baz' -- '", + "obfuscated": [ + "SELECT * FROM t WHERE ? \n bar=? ?", + "SELECT * FROM t WHERE ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "mixed_quotes_comments_and_newlines", + "sql": "SELECT * FROM t WHERE -- '\n /* ' */ c2='xxx' /* ' */\n c='x\n xx' -- '", + "obfuscated": [ + "SELECT * FROM t WHERE ?\n ? c2=? ?\n c=? ?", + "SELECT * FROM t WHERE ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "mixed_quotes_end_of_line_comments", + "sql": "SELECT * FROM t WHERE -- '\n c='x\n xx' -- '", + "obfuscated": [ + "SELECT * FROM t WHERE ?\n c=? ?", + "SELECT * FROM t WHERE ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "quote_delimiters_in_comments", + "sql": "SELECT * FROM foo WHERE col='value1' AND /* don't */ col2='value1' /* won't */", + "obfuscated": [ + "SELECT * FROM foo WHERE col=? AND ? col2=? ?", + "SELECT * FROM foo WHERE col=? AND ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "malformed/unterminated_double_quoted_string.mysql", + "sql": "SELECT * FROM table WHERE foo='bar' AND baz=\"nothing to see here'", + "dialects": [ + "mysql" + ], + "obfuscated": [ + "?" + ], + "malformed": true + }, + { + "name": "malformed/unterminated_single_quoted_string", + "sql": "SELECT * FROM table WHERE foo='bar' AND baz='nothing to see here", + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ], + "obfuscated": [ + "?" + ], + "malformed": true + }, + { + "name": "dollar_quotes", + "sql": "SELECT * FROM \"foo\" WHERE \"foo\" = $a$dollar quotes can be $b$nested$b$$a$ and bar = 'baz'", + "obfuscated": [ + "SELECT * FROM \"foo\" WHERE \"foo\" = ? and bar = ?" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "variable_substitution_not_mistaken_for_dollar_quotes", + "sql": "INSERT INTO \"foo\" (\"bar\", \"baz\", \"qux\") VALUES ($1, $2, $3) RETURNING \"id\"", + "obfuscated": [ + "INSERT INTO \"foo\" (\"bar\", \"baz\", \"qux\") VALUES ($?, $?, $?) RETURNING \"id\"" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "non_quote_escape", + "sql": "select * from foo where bar = 'some\\tthing' and baz = 10", + "obfuscated": [ + "select * from foo where bar = ? and baz = ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "end_of_string_backslash_and_line_comment_with_quite", + "sql": "select * from users where user = 'user1\\' password = 'secret 2' -- ->don't count this quote", + "obfuscated": [ + "select * from users where user = ?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "oracle_bracket_quote", + "sql": "select * from foo where bar=q'[baz's]' and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "oracle" + ] + }, + { + "name": "oracle_brace_quote", + "sql": "select * from foo where bar=q'{baz's}' and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "oracle" + ] + }, + { + "name": "oracle_angle_quote", + "sql": "select * from foo where bar=q'' and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "oracle" + ] + }, + { + "name": "oracle_paren_quote", + "sql": "select * from foo where bar=q'(baz's)' and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "oracle" + ] + }, + { + "name": "cassandra_blobs", + "sql": "select * from foo where bar=0xabcdef123 and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "cassandra", + "sqlite" + ] + }, + { + "name": "hex_literals", + "sql": "select * from foo where bar=0x2F and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "mysql", + "cassandra", + "sqlite" + ] + }, + { + "name": "exponential_literals", + "sql": "select * from foo where bar=1.234e-5 and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "negative_integer_literals", + "sql": "select * from foo where bar=-1.234e-5 and x=-5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra", + "sqlite" + ] + }, + { + "name": "uuid", + "sql": "select * from foo where bar=01234567-89ab-cdef-0123-456789abcdef and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "postgres", + "cassandra" + ] + }, + { + "name": "uuid_with_braces", + "sql": "select * from foo where bar={01234567-89ab-cdef-0123-456789abcdef} and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "uuid_no_dashes", + "sql": "select * from foo where bar=0123456789abcdef0123456789abcdef and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "uuid_random_dashes", + "sql": "select * from foo where bar={012-345678-9abc-def012345678-9abcdef} and x=5", + "obfuscated": [ + "select * from foo where bar=? and x=?" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "booleans", + "sql": "select * from truestory where bar=true and x=FALSE", + "obfuscated": [ + "select * from truestory where bar=? and x=?" + ], + "dialects": [ + "mysql", + "postgres", + "cassandra", + "sqlite" + ] + }, + { + "name": "in_clause_digits", + "sql": "select * from foo where bar IN (123, 456, 789)", + "obfuscated": [ + "select * from foo where bar IN (?, ?, ?)" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra" + ] + }, + { + "name": "in_clause_strings", + "sql": "select * from foo where bar IN ('asdf', 'fdsa')", + "obfuscated": [ + "select * from foo where bar IN (?, ?)" + ], + "dialects": [ + "mysql", + "postgres", + "oracle", + "cassandra" + ] + }, + { + "name": "prepended_comments_with_quotes.postgres", + "sql": "/*application:Demo,controller:posts,action:update*/ UPDATE \"posts\" SET \"updated_at\" = '2023-11-01 19:02:34.795909' WHERE \"posts\".\"id\" = 3", + "obfuscated": [ + "? UPDATE \"posts\" SET \"updated_at\" = ? WHERE \"posts\".\"id\" = ?" + ], + "dialects": [ + "postgres" + ] + }, + { + "name": "prepended_comments_with_quotes.mysql", + "sql": "/*action='show',application='TrilogyTest',controller='users'*/ SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1", + "obfuscated": [ + "? SELECT `users`.* FROM `users` WHERE `users`.`id` = ? LIMIT ?" + ], + "dialects": [ + "mysql" + ] + }, + { + "name": "prepended_multiline_comments_with_quotes.mysql", + "sql": "/*action='show',\napplication='TrilogyTest',controller='users'*/\nSELECT `users`.*\nFROM `users`\nWHERE `users`.`id` = 1 LIMIT 1", + "obfuscated": [ + "?\nSELECT `users`.*\nFROM `users`\nWHERE `users`.`id` = ? LIMIT ?" + ], + "dialects": [ + "mysql" + ] + } +] diff --git a/helpers/sql-processor/test/helpers/sql_obfuscation_test.rb b/helpers/sql-processor/test/helpers/sql_obfuscation_test.rb new file mode 100644 index 0000000000..39229d47f1 --- /dev/null +++ b/helpers/sql-processor/test/helpers/sql_obfuscation_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. + +require_relative '../test_helper' + +class SqlObfuscationTest < Minitest::Test + def test_named_arg_defaults_obfuscates + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expected = 'SELECT * from users where users.id = ? and users.email = ?' + result = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql) + + assert_equal(expected, result) + end + + def test_obfuscation_returns_message_when_limit_is_reached + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com'" + expected = 'SQL not obfuscated, query exceeds 42 characters' + result = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, obfuscation_limit: 42) + + assert_equal(expected, result) + end + + def test_non_utf_8_encoded_string_obfuscates_with_mysql + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com\255'" + expected = 'SELECT * from users where users.id = ? and users.email = ?' + result = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, adapter: :mysql) + + assert_equal(expected, result) + end + + def test_non_utf_8_encoded_string_obfuscates_with_postgres + sql = "SELECT * from users where users.id = 1 and users.email = 'test@test.com\255'" + expected = 'SELECT * from users where users.id = ? and users.email = ?' + result = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql, adapter: :postgres) + + assert_equal(expected, result) + end + + def test_statement_with_emoji_encodes_utf_8_and_obfuscates + sql = "SELECT * from users where users.id = 1 and users.email = 'test@😄.com'" + expected = 'SELECT * from users where users.id = ? and users.email = ?' + result = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(sql) + + assert_equal(expected, result) + end + + # The following tests and their corresponding fixture are based on code from + # the New Relic Ruby agent. + # source: https://github.com/newrelic/newrelic-ruby-agent/blob/cb72bb5fab3fb318613421c86863a5ccdd2ff250/test/new_relic/agent/database/sql_obfuscation_test.rb + + FAILED_TO_OBFUSCATE_MESSAGE = 'Failed to obfuscate SQL query - quote characters remained after obfuscation' + + def build_failure_message(statement, dialect, acceptable_outputs, actual_output) + msg = "Failed to obfuscate #{dialect} query correctly.\n" + msg << "Input: #{statement}\n" + if acceptable_outputs.size == 1 + msg << "Expected: #{acceptable_outputs.first}\n" + else + msg << "Acceptable outputs:\n" + acceptable_outputs.each do |output| + msg << " #{output}\n" + end + end + msg << "Actual: #{actual_output}\n" + msg + end + + def self.load_fixture + data = File.read("#{Dir.pwd}/test/fixtures/sql_obfuscation.json") + JSON.parse(data) + end + + load_fixture.each do |test_case| + name = test_case['name'] + query = test_case['sql'] + acceptable_outputs = test_case['obfuscated'] + dialects = test_case['dialects'] + + # If the entire query is obfuscated because it's malformed, we use a + # placeholder message instead of just '?', so add that to the acceptable + # outputs. + acceptable_outputs << FAILED_TO_OBFUSCATE_MESSAGE if test_case['malformed'] + + dialects.each do |dialect| + define_method(:"test_sql_obfuscation_#{name}_#{dialect}") do + actual_obfuscated = OpenTelemetry::Helpers::SqlObfuscation.obfuscate_sql(query, adapter: dialect.to_sym) + message = build_failure_message(query, dialect, acceptable_outputs, actual_obfuscated) + + assert_includes(acceptable_outputs, actual_obfuscated, message) + end + end + end + ## End New Relic tests +end diff --git a/helpers/sql-processor/test/test_helper.rb b/helpers/sql-processor/test/test_helper.rb new file mode 100644 index 0000000000..419b09eb04 --- /dev/null +++ b/helpers/sql-processor/test/test_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'simplecov' +require 'bundler/setup' +Bundler.require(:default, :development, :test) + +require 'minitest/autorun' +require 'opentelemetry-helpers-sql-processor' diff --git a/instrumentation/action_pack/CHANGELOG.md b/instrumentation/action_pack/CHANGELOG.md index 675a46834c..ce8a012fd8 100644 --- a/instrumentation/action_pack/CHANGELOG.md +++ b/instrumentation/action_pack/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History: opentelemetry-instrumentation-action_pack +### v0.14.1 / 2025-10-07 + +* FIXED: Unify rack middleware_args + ### v0.14.0 / 2025-09-30 * ADDED: Bump minimum API Version to 1.7 diff --git a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb index 54c47658a5..630aa1e11e 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/railtie.rb @@ -11,21 +11,9 @@ module ActionPack class Railtie < ::Rails::Railtie config.before_initialize do |app| OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.install({}) - - stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') - values = stability_opt_in.split(',').map(&:strip) - - rack_middleware_args = if values.include?('http/dup') - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup - elsif values.include?('http') - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable - else - OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args - end - app.middleware.insert_before( 0, - *rack_middleware_args + *OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args ) end end diff --git a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/version.rb b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/version.rb index d7a58852eb..f5ab8ff3ca 100644 --- a/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/version.rb +++ b/instrumentation/action_pack/lib/opentelemetry/instrumentation/action_pack/version.rb @@ -7,7 +7,7 @@ module OpenTelemetry module Instrumentation module ActionPack - VERSION = '0.14.0' + VERSION = '0.14.1' end end end diff --git a/instrumentation/active_job/CHANGELOG.md b/instrumentation/active_job/CHANGELOG.md index 4bc353affa..f7aa25300b 100644 --- a/instrumentation/active_job/CHANGELOG.md +++ b/instrumentation/active_job/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History: opentelemetry-instrumentation-active_job +### v0.9.2 / 2025-10-07 + +* DOCS: Enhance README + ### v0.9.1 / 2025-09-30 * FIXED: Min OTel Ruby API 1.7 diff --git a/instrumentation/active_job/README.md b/instrumentation/active_job/README.md index 52ad445101..fde00053e5 100644 --- a/instrumentation/active_job/README.md +++ b/instrumentation/active_job/README.md @@ -30,6 +30,27 @@ OpenTelemetry::SDK.configure do |c| end ``` +## Configuration Options + +The instrumentation supports the following configuration options: + +- **span_naming:** Determines how span names are generated. + - `:job_class` – Span names are set to ` `. + - `:queue` – Span names are set to ` `. + - Default: `:queue` +- **force_flush:** If enabled, all completed spans are synchronously flushed at + the end of each job execution. This is recommended for job systems that fork + worker processes, such as Resque. + - Default: `false` +- **propagation_style:** Controls how job execution traces are related to the + trace where the job was enqueued. + - `:link` – The job runs in a separate trace, with its initial span linked to + the enqueuing span via a Span Link. + - `:child` – The job runs in the same trace, as a direct child of the + enqueuing span. + - `:none` – No explicit link between the job execution and the enqueuing span. + - Default: `:link` + ## Active Support Instrumentation Earlier versions of this instrumentation relied on registering custom `around_perform` hooks in order to deal with limitations diff --git a/instrumentation/active_job/lib/opentelemetry/instrumentation/active_job/version.rb b/instrumentation/active_job/lib/opentelemetry/instrumentation/active_job/version.rb index e116bf0d00..82d1c283a0 100644 --- a/instrumentation/active_job/lib/opentelemetry/instrumentation/active_job/version.rb +++ b/instrumentation/active_job/lib/opentelemetry/instrumentation/active_job/version.rb @@ -7,7 +7,7 @@ module OpenTelemetry module Instrumentation module ActiveJob - VERSION = '0.9.1' + VERSION = '0.9.2' end end end diff --git a/instrumentation/all/Gemfile b/instrumentation/all/Gemfile index 4c1159e105..bc9f6d0400 100644 --- a/instrumentation/all/Gemfile +++ b/instrumentation/all/Gemfile @@ -29,7 +29,7 @@ group :test do .sort .each { |dir| gem "opentelemetry-helpers-#{dir}", path: "../../helpers/#{dir}" } - excluded_instrumentations = %w[. .. all] + excluded_instrumentations = %w[. .. all logger] Dir.entries('../') .select { |entry| File.directory?(File.join('../', entry)) } .reject { |entry| excluded_instrumentations.include?(entry) } diff --git a/instrumentation/all/lib/opentelemetry/instrumentation/all.rb b/instrumentation/all/lib/opentelemetry/instrumentation/all.rb index 81a26ae96a..966ed967df 100644 --- a/instrumentation/all/lib/opentelemetry/instrumentation/all.rb +++ b/instrumentation/all/lib/opentelemetry/instrumentation/all.rb @@ -5,8 +5,6 @@ # SPDX-License-Identifier: Apache-2.0 require 'opentelemetry-instrumentation-anthropic' -require 'opentelemetry-instrumentation-gruf' -require 'opentelemetry-instrumentation-trilogy' require 'opentelemetry-instrumentation-active_support' require 'opentelemetry-instrumentation-action_pack' require 'opentelemetry-instrumentation-active_job' @@ -31,6 +29,7 @@ require 'opentelemetry-instrumentation-grape' require 'opentelemetry-instrumentation-graphql' require 'opentelemetry-instrumentation-grpc' +require 'opentelemetry-instrumentation-gruf' require 'opentelemetry-instrumentation-http_client' require 'opentelemetry-instrumentation-mongo' require 'opentelemetry-instrumentation-mysql2' @@ -48,6 +47,7 @@ require 'opentelemetry-instrumentation-ruby_kafka' require 'opentelemetry-instrumentation-sidekiq' require 'opentelemetry-instrumentation-sinatra' +require 'opentelemetry-instrumentation-trilogy' # OpenTelemetry is an open source observability framework, providing a # general-purpose API, SDK, and related tools required for the instrumentation diff --git a/instrumentation/logger/.rubocop.yml b/instrumentation/logger/.rubocop.yml new file mode 100644 index 0000000000..1248a2f825 --- /dev/null +++ b/instrumentation/logger/.rubocop.yml @@ -0,0 +1 @@ +inherit_from: ../../.rubocop.yml diff --git a/instrumentation/logger/.yardopts b/instrumentation/logger/.yardopts new file mode 100644 index 0000000000..0d5d214884 --- /dev/null +++ b/instrumentation/logger/.yardopts @@ -0,0 +1,9 @@ +--no-private +--title=OpenTelemetry Logger Instrumentation +--markup=markdown +--main=README.md +./lib/opentelemetry/instrumentation/**/*.rb +./lib/opentelemetry/instrumentation.rb +- +README.md +CHANGELOG.md diff --git a/instrumentation/logger/Appraisals b/instrumentation/logger/Appraisals new file mode 100644 index 0000000000..abe715a1c2 --- /dev/null +++ b/instrumentation/logger/Appraisals @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +%w[7.1.0 7.2.0].each do |version| + appraise "rails-#{version}" do + gem 'rails', "~> #{version}" + end +end + +appraise 'rails-latest' do + gem 'rails' +end diff --git a/instrumentation/logger/CHANGELOG.md b/instrumentation/logger/CHANGELOG.md new file mode 100644 index 0000000000..c62749f184 --- /dev/null +++ b/instrumentation/logger/CHANGELOG.md @@ -0,0 +1 @@ +# Release History: opentelemetry-instrumentation-logger diff --git a/instrumentation/logger/Gemfile b/instrumentation/logger/Gemfile new file mode 100644 index 0000000000..ef7549e066 --- /dev/null +++ b/instrumentation/logger/Gemfile @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +source 'https://rubygems.org' + +# DO NOT ADD DEPENDENCIES HERE! +# Please declare a minimum development dependency in the gemspec, +# then target specific versions in the Appraisals file. + +gemspec + +group :test do + gem 'appraisal', '~> 2.5' + gem 'bundler', '~> 2.4' + gem 'minitest', '~> 5.0' + gem 'opentelemetry-sdk', '~> 1.0' + gem 'opentelemetry-logs-sdk', '~> 0.1' + gem 'opentelemetry-test-helpers', '~> 0.3' + gem 'rubocop', '~> 1.80.2' + gem 'rubocop-performance', '~> 1.26.0' + gem 'simplecov', '~> 0.22.0' + gem 'webmock', '~> 3.24' + gem 'yard', '~> 0.9' + gem 'opentelemetry-instrumentation-base', path: '../base' + if RUBY_VERSION >= '3.4' + gem 'base64' + gem 'mutex_m' + end +end diff --git a/instrumentation/logger/LICENSE b/instrumentation/logger/LICENSE new file mode 100644 index 0000000000..1ef7dad2c5 --- /dev/null +++ b/instrumentation/logger/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/instrumentation/logger/README.md b/instrumentation/logger/README.md new file mode 100644 index 0000000000..73231dd515 --- /dev/null +++ b/instrumentation/logger/README.md @@ -0,0 +1,65 @@ +# OpenTelemetry Logger Instrumentation + +The Logger instrumentation is a community-maintained bridge for the Ruby [logger][logger-home] standard library. + +## How do I get started? + +Install the gem using: + +```shell +gem install opentelemetry-instrumentation-logger +``` + +Or, if you use [bundler][bundler-home], include `opentelemetry-instrumentation-logger` in your `Gemfile`. + +## Usage + +To use the instrumentation, call `use` with the name of the instrumentation: + +```ruby +OpenTelemetry::SDK.configure do |c| + c.use 'OpenTelemetry::Instrumentation::Logger' +end +``` + +Alternatively, you can call `use_all` to install all the available instrumentation. + +```ruby +OpenTelemetry::SDK.configure do |c| + c.use_all +end +``` + +## Examples + +Example usage can be seen in the `./example/logger.rb` file [here](https://github.com/open-telemetry/opentelemetry-ruby-contrib/blob/main/instrumentation/logger/example/logger.rb) + +## Development + +The test suite leverages [appraisal][appraisal] to verify the integration across multiple Rails versions. To run the tests with appraisal: + +```shell +cd instrumentation/logger +bundle exec appraisal generate +bundle exec appraisal install +bundle exec appraisal rake test +``` + +## How can I get involved? + +The `opentelemetry-instrumentation-logger` gem source is [on github][repo-github], along with related gems including `opentelemetry-logs-api` and `opentelemetry-logs-sdk`. + +The OpenTelemetry Ruby gems are maintained by the OpenTelemetry-Ruby special interest group (SIG). You can get involved by joining us in [GitHub Discussions][discussions-url] 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-instrumentation-logger` gem is distributed under the Apache 2.0 license. See [LICENSE][license-github] for more information. + +[appraisal]: https://github.com/thoughtbot/appraisal +[bundler-home]: https://bundler.io +[logger-home]: https://github.com/ruby/logger +[repo-github]: https://github.com/open-telemetry/opentelemetry-ruby +[license-github]: https://github.com/open-telemetry/opentelemetry-ruby-contrib/blob/main/LICENSE +[ruby-sig]: https://github.com/open-telemetry/community#ruby-sig +[community-meetings]: https://github.com/open-telemetry/community#community-meetings +[discussions-url]: https://github.com/open-telemetry/opentelemetry-ruby/discussions diff --git a/instrumentation/logger/Rakefile b/instrumentation/logger/Rakefile new file mode 100644 index 0000000000..1a64ba842e --- /dev/null +++ b/instrumentation/logger/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/instrumentation/logger/example/logger.rb b/instrumentation/logger/example/logger.rb new file mode 100644 index 0000000000..6a8901e93d --- /dev/null +++ b/instrumentation/logger/example/logger.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile(true) do + source 'https://rubygems.org' + + gem 'logger' + gem 'opentelemetry-sdk' + gem 'opentelemetry-logs-sdk' + gem 'opentelemetry-instrumentation-logger', path: '../' +end + +require 'opentelemetry/sdk' +require 'opentelemetry-logs-sdk' +require 'opentelemetry-instrumentation-logger' +require 'logger' + +# Don't attempt to export traces, Logger instrumentation only emits logs. +ENV['OTEL_TRACES_EXPORTER'] ||= 'none' +ENV['OTEL_LOGS_EXPORTER'] ||= 'console' + +OpenTelemetry::SDK.configure do |c| + c.use 'OpenTelemetry::Instrumentation::Logger' +end + +at_exit do + OpenTelemetry.logger_provider.shutdown +end + +logger = Logger.new(STDOUT) +logger.debug('emerald ash borer') diff --git a/instrumentation/logger/lib/opentelemetry-instrumentation-logger.rb b/instrumentation/logger/lib/opentelemetry-instrumentation-logger.rb new file mode 100644 index 0000000000..c034f140f8 --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry-instrumentation-logger.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative 'opentelemetry/instrumentation' diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation.rb b/instrumentation/logger/lib/opentelemetry/instrumentation.rb new file mode 100644 index 0000000000..99cd544da7 --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# OpenTelemetry is an open source observability framework, providing a +# general-purpose API, SDK, and related tools required for the instrumentation +# of cloud-native software, frameworks, and libraries. +# +# The OpenTelemetry module provides global accessors for telemetry objects. +# See the documentation for the `opentelemetry-api` gem for details. +module OpenTelemetry + # The Instrumentation module is a namespace for all OpenTelemetry Instrumentation libraries + module Instrumentation + end +end + +require_relative 'instrumentation/logger' diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger.rb new file mode 100644 index 0000000000..cdfcfb47cd --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry' +require 'opentelemetry-instrumentation-base' + +module OpenTelemetry + module Instrumentation + # Contains the OpenTelemetry instrumentation for the Logger gem + module Logger + NAME = 'opentelemetry-instrumentation-logger' + end + end +end + +require_relative 'logger/instrumentation' +require_relative 'logger/version' diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger/instrumentation.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/instrumentation.rb new file mode 100644 index 0000000000..44fc83d8c7 --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/instrumentation.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Logger + # The `OpenTelemetry::Instrumentation::Logger::Instrumentation` class contains logic to detect and install the + # Ruby Logger library instrumentation. + # + # Installation and configuration of this instrumentation is done within the + # {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry/SDK#configure-instance_method OpenTelemetry::SDK#configure} + # block, calling {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry%2FSDK%2FConfigurator:use use()} + # or {https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry%2FSDK%2FConfigurator:use_all use_all()}. + # + class Instrumentation < OpenTelemetry::Instrumentation::Base + install do |_config| + require_dependencies + patch + end + + present do + defined?(::Logger) && defined?(::OpenTelemetry::SDK::Logs) + end + + private + + def patch + ::Logger.prepend(Patches::Logger) + active_support_broadcast_logger_patch + active_support_patch + end + + def require_dependencies + require_relative 'patches/logger' + end + + def active_support_patch + return unless defined?(::ActiveSupport::Logger) && !defined?(::ActiveSupport::BroadcastLogger) + + require_relative 'patches/active_support_logger' + ::ActiveSupport::Logger.singleton_class.prepend(Patches::ActiveSupportLogger) + end + + def active_support_broadcast_logger_patch + return unless defined?(::ActiveSupport::BroadcastLogger) + + require_relative 'patches/active_support_broadcast_logger' + ::ActiveSupport::BroadcastLogger.prepend(Patches::ActiveSupportBroadcastLogger) + end + end + end + end +end diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger.rb new file mode 100644 index 0000000000..8d310832ea --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Logger + module Patches + # Patches for the ActiveSupport::BroadcastLogger class included in Rails 7.1+ + module ActiveSupportBroadcastLogger + def add(*args) + emit_one_broadcast(*args) { super } + end + + def debug(*args) + emit_one_broadcast(*args) { super } + end + + def info(*args) + emit_one_broadcast(*args) { super } + end + + def warn(*args) + emit_one_broadcast(*args) { super } + end + + def error(*args) + emit_one_broadcast(*args) { super } + end + + def fatal(*args) + emit_one_broadcast(*args) { super } + end + + def unknown(*args) + emit_one_broadcast(*args) { super } + end + + private + + # Emit logs from only one of the loggers in the broadcast. + # Set @skip_otel_emit to `true` to the rest of the loggers before emitting the logs. + # Set @skip_otel_emit to `false` after the log is emitted. + def emit_one_broadcast(*args) + broadcasts[1..-1].each { |broadcasted_logger| broadcasted_logger.instance_variable_set(:@skip_otel_emit, true) } + yield + broadcasts.each { |broadcasted_logger| broadcasted_logger.instance_variable_set(:@skip_otel_emit, false) } + end + end + end + end + end +end diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_logger.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_logger.rb new file mode 100644 index 0000000000..6d67072bb2 --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/active_support_logger.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Logger + module Patches + # Patches for the ActiveSupport::Logger class included in Rails + module ActiveSupportLogger + # The ActiveSupport::Logger.broadcast method emits identical logs to + # multiple destinations. This instance variable will prevent the broadcasted + # destinations from generating OpenTelemetry log record objects. + # Available in Rails 7.0 and below + def broadcast(logger) + logger.instance_variable_set(:@skip_otel_emit, true) + super + end + end + end + end + end +end diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/logger.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/logger.rb new file mode 100644 index 0000000000..63827882c3 --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/patches/logger.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Logger + module Patches + # Instrumention for methods from Ruby's Logger class + module Logger + attr_writer :skip_otel_emit + + def format_message(severity, datetime, progname, msg) + formatted_message = super + return formatted_message if skip_otel_emit? + + OpenTelemetry.logger_provider.logger( + name: OpenTelemetry::Instrumentation::Logger::NAME, + version: OpenTelemetry::Instrumentation::Logger::VERSION + ).on_emit( + severity_text: severity, + severity_number: severity_number(severity), + timestamp: datetime, + body: msg, + context: OpenTelemetry::Context.current + ) + formatted_message + end + + private + + def skip_otel_emit? + @skip_otel_emit || false + end + + def severity_number(severity) + case severity.downcase + when 'debug' + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_DEBUG + when 'info' + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_INFO + when 'warn' + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_WARN + when 'error' + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_ERROR + when 'fatal' + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_FATAL + else + OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_UNSPECIFIED + end + end + end + end + end + end +end diff --git a/instrumentation/logger/lib/opentelemetry/instrumentation/logger/version.rb b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/version.rb new file mode 100644 index 0000000000..4157e9b04b --- /dev/null +++ b/instrumentation/logger/lib/opentelemetry/instrumentation/logger/version.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Instrumentation + module Logger + VERSION = '0.1.0' + end + end +end diff --git a/instrumentation/logger/opentelemetry-instrumentation-logger.gemspec b/instrumentation/logger/opentelemetry-instrumentation-logger.gemspec new file mode 100644 index 0000000000..c0d4e8d669 --- /dev/null +++ b/instrumentation/logger/opentelemetry-instrumentation-logger.gemspec @@ -0,0 +1,39 @@ +# 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/instrumentation/logger/version' + +Gem::Specification.new do |spec| + spec.name = 'opentelemetry-instrumentation-logger' + spec.version = OpenTelemetry::Instrumentation::Logger::VERSION + spec.authors = ['OpenTelemetry Authors'] + spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] + + spec.summary = 'Logger instrumentation for the OpenTelemetry framework' + spec.description = 'Logger instrumentation for the OpenTelemetry framework' + 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', '.yardopts'] + spec.require_paths = ['lib'] + spec.required_ruby_version = ">= #{File.read(File.expand_path('../../gemspecs/RUBY_REQUIREMENT', __dir__))}" + + spec.add_dependency 'opentelemetry-instrumentation-base', '~> 0.24' + spec.add_dependency 'opentelemetry-logs-api', '~> 0.1' + + 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/main/instrumentation/logger' + 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 + + spec.post_install_message = File.read(File.expand_path('../../gemspecs/POST_INSTALL_MESSAGE', __dir__)) +end diff --git a/instrumentation/logger/test/opentelemetry/instrumentation/logger/instrumentation_test.rb b/instrumentation/logger/test/opentelemetry/instrumentation/logger/instrumentation_test.rb new file mode 100644 index 0000000000..36da47604a --- /dev/null +++ b/instrumentation/logger/test/opentelemetry/instrumentation/logger/instrumentation_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../lib/opentelemetry/instrumentation/logger' + +describe OpenTelemetry::Instrumentation::Logger do + let(:instrumentation) { OpenTelemetry::Instrumentation::Logger::Instrumentation.instance } + + it 'has #name' do + _(instrumentation.name).must_equal 'OpenTelemetry::Instrumentation::Logger' + end + + it 'has #version' do + _(instrumentation.version).wont_be_nil + _(instrumentation.version).wont_be_empty + end + + describe '#install' do + it 'accepts argument' do + _(instrumentation.install({})).must_equal(true) + instrumentation.instance_variable_set(:@installed, false) + end + end +end diff --git a/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger_test.rb b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger_test.rb new file mode 100644 index 0000000000..449b4e5632 --- /dev/null +++ b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/logger/patches/active_support_broadcast_logger' + +describe OpenTelemetry::Instrumentation::Logger::Patches::ActiveSupportBroadcastLogger do + let(:instrumentation) { OpenTelemetry::Instrumentation::Logger::Instrumentation.instance } + let(:logger) { Logger.new(LOG_STREAM) } + let(:logger2) { Logger.new(BROADCASTED_STREAM) } + let(:broadcast) { ActiveSupport::BroadcastLogger.new(logger, logger2) } + + before do + skip unless defined?(ActiveSupport::BroadcastLogger) + EXPORTER.reset + instrumentation.install + end + + after { instrumentation.instance_variable_set(:@installed, false) } + + describe '#add' do + it 'emits the log to the broadcasted loggers' do + body = 'Ground control to Major Tom' + broadcast.add(Logger::DEBUG, body) + + assert_includes(LOG_STREAM.string, body) + assert_includes(BROADCASTED_STREAM.string, body) + end + + it 'emits only one OpenTelemetry log record' do + body = 'Wake up, you sleepyhead' + broadcast.add(Logger::DEBUG, body) + log_records = EXPORTER.emitted_log_records + + assert_equal 1, log_records.size + assert_equal 'DEBUG', log_records.first.severity_text + assert_equal body, log_records.first.body + end + end + + describe '#unknown' do + it 'emits the log to the broadcasted loggers' do + body = 'I know when to go out' + broadcast.unknown(body) + + assert_includes(LOG_STREAM.string, body) + assert_includes(BROADCASTED_STREAM.string, body) + end + + it 'emits only one OpenTelemetry log record' do + body = "You've got your mother in a whirl" + broadcast.unknown(body) + + log_records = EXPORTER.emitted_log_records + + assert_equal 1, log_records.size + assert_equal 'ANY', log_records.first.severity_text + assert_equal body, log_records.first.body + end + end + + %w[debug info warn error fatal].each do |severity| + describe "##{severity}" do + it 'emits the log to the broadcasted loggers' do + body = "Still don't know what I was waiting for...#{rand(7)}" + broadcast.send(severity.to_sym, body) + + assert_includes(LOG_STREAM.string, body) + assert_includes(BROADCASTED_STREAM.string, body) + end + + it 'emits only one OpenTelemetry log record' do + body = "They pulled in just behind the bridge...#{rand(7)}" + broadcast.send(severity.to_sym, body) + + log_records = EXPORTER.emitted_log_records + + assert_equal 1, log_records.size + assert_equal severity.upcase, log_records.first.severity_text + assert_equal body, log_records.first.body + end + end + end +end diff --git a/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_logger_test.rb b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_logger_test.rb new file mode 100644 index 0000000000..9eff49e674 --- /dev/null +++ b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/active_support_logger_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/logger/patches/active_support_logger' + +describe OpenTelemetry::Instrumentation::Logger::Patches::ActiveSupportLogger do + let(:instrumentation) { OpenTelemetry::Instrumentation::Logger::Instrumentation.instance } + let(:main_logger) { ActiveSupport::Logger.new(LOG_STREAM) } + let(:broadcasted_logger) { ActiveSupport::Logger.new(BROADCASTED_STREAM) } + + before do + skip unless defined?(ActiveSupport::Logger) && !defined?(ActiveSupport::BroadcastLogger) + EXPORTER.reset + Rails.logger = main_logger.extend(ActiveSupport::Logger.broadcast(broadcasted_logger)) + instrumentation.install + end + + after { instrumentation.instance_variable_set(:@installed, false) } + + describe '#broadcast' do + it 'streams the log to the Rails.logger' do + msg = "spruce #{rand(6)}" + Rails.logger.debug(msg) + + assert_match(/#{msg}/, LOG_STREAM.string) + end + + it 'streams the broadcasted log' do + msg = "willow #{rand(6)}" + Rails.logger.debug(msg) + + assert_match(/#{msg}/, BROADCASTED_STREAM.string) + end + + it 'emits the log record' do + msg = "hemlock #{rand(6)}" + Rails.logger.debug(msg) + log_record = EXPORTER.emitted_log_records.first + + assert_match(/#{msg}/, log_record.body) + end + + it 'emits the log record only once' do + msg = "juniper #{rand(6)}" + Rails.logger.debug(msg) + + log_records = EXPORTER.emitted_log_records + assert_equal 1, log_records.size + assert_match(/#{msg}/, log_records.first.body) + end + end +end diff --git a/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/logger_test.rb b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/logger_test.rb new file mode 100644 index 0000000000..7c91621601 --- /dev/null +++ b/instrumentation/logger/test/opentelemetry/instrumentation/logger/patches/logger_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +require_relative '../../../../../lib/opentelemetry/instrumentation/logger/patches/logger' + +describe OpenTelemetry::Instrumentation::Logger::Patches::Logger do + let(:instrumentation) { OpenTelemetry::Instrumentation::Logger::Instrumentation.instance } + let(:exporter) { EXPORTER } + let(:log_record) { exporter.emitted_log_records.first } + let(:log_stream) { StringIO.new } + let(:ruby_logger) { Logger.new(log_stream) } + let(:msg) { 'message' } + let(:config) { {} } + + before do + exporter.reset + instrumentation.install(config) + end + + after { instrumentation.instance_variable_set(:@installed, false) } + + describe '#format_message' do + it 'logs the formatted message to the correct source' do + ruby_logger.debug(msg) + assert_match(/DEBUG -- : #{msg}/, log_stream.string) + end + + it 'sets the OTel logger instrumentation name and version (default case)' do + ruby_logger.debug(msg) + assert_equal(OpenTelemetry::Instrumentation::Logger::NAME, log_record.instrumentation_scope.name) + assert_equal(OpenTelemetry::Instrumentation::Logger::VERSION, log_record.instrumentation_scope.version) + end + + it 'sets log record attributes based on the Ruby log' do + timestamp = Time.now + nano_timestamp = OpenTelemetry::SDK::Logs::LogRecord.new.send(:to_integer_nanoseconds, timestamp) + + Time.stub(:now, timestamp) do + ruby_logger.debug(msg) + assert_equal(msg, log_record.body) + assert_equal('DEBUG', log_record.severity_text) + assert_equal(5, log_record.severity_number) + assert_equal(nano_timestamp, log_record.timestamp) + end + end + + it 'does not emit when @skip_otel_emit is true' do + ruby_logger.instance_variable_set(:@skip_otel_emit, true) + ruby_logger.debug(msg) + assert_nil(log_record) + end + + it 'turns the severity into a number' do + ruby_logger.debug(msg) + assert_equal(5, log_record.severity_number) + end + + it 'safely handles unknown severity number translations' do + ruby_logger.send(:format_message, 'CUSTOM_SEVERITY', Time.now, nil, msg) + assert_equal(0, log_record.severity_number) + end + end +end diff --git a/instrumentation/logger/test/test_helper.rb b/instrumentation/logger/test/test_helper.rb new file mode 100644 index 0000000000..34e3f0b51b --- /dev/null +++ b/instrumentation/logger/test/test_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'logger' +require 'bundler/setup' +Bundler.require(:default, :development, :test) + +require 'minitest/autorun' +require 'test_helpers/app_config' + +EXPORTER = OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new +log_record_processor = OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(EXPORTER) +LOG_STREAM = StringIO.new +BROADCASTED_STREAM = StringIO.new + +OpenTelemetry::SDK.configure do |c| + c.error_handler = ->(exception:, message:) { raise(exception || message) } + c.logger = Logger.new($stderr, level: ENV.fetch('OTEL_LOG_LEVEL', 'fatal').to_sym) + c.add_log_record_processor log_record_processor +end + +# Create a globally available Rails app, this should be used in test unless +# specifically testing behaviour with different initialization configs. +DEFAULT_RAILS_APP = AppConfig.initialize_app +Rails.application = DEFAULT_RAILS_APP diff --git a/instrumentation/logger/test/test_helpers/app_config.rb b/instrumentation/logger/test/test_helpers/app_config.rb new file mode 100644 index 0000000000..710c6de615 --- /dev/null +++ b/instrumentation/logger/test/test_helpers/app_config.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +class Application < Rails::Application; end +require 'action_controller/railtie' + +module AppConfig + extend self + + def initialize_app(use_exceptions_app: false, remove_rack_tracer_middleware: false) + app = Application.new + app.config.secret_key_base = 'secret_key_base' + + # Ensure we don't see this Rails warning when testing + app.config.eager_load = false + app.config.enable_reloading = false + + # Prevent tests from creating log/*.log + level = ENV.fetch('OTEL_LOG_LEVEL', 'fatal').to_sym + app.config.logger = ActiveSupport::Logger.new(LOG_STREAM, level: level) + app.config.log_level = level + app.config.filter_parameters = [:param_to_be_filtered] + app.config.load_defaults([Rails::VERSION::MAJOR, Rails::VERSION::MINOR].compact.join('.')) + + app.initialize! + + app + end +end diff --git a/instrumentation/rack/CHANGELOG.md b/instrumentation/rack/CHANGELOG.md index f170f3e604..189fd073b7 100644 --- a/instrumentation/rack/CHANGELOG.md +++ b/instrumentation/rack/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History: opentelemetry-instrumentation-rack +### v0.28.2 / 2025-10-07 + +* FIXED: Unify rack middleware_args + ### v0.28.1 / 2025-09-30 * FIXED: Min OTel Ruby API 1.7 diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb index 9c3dc10b81..2089db4284 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/instrumentation.rb @@ -32,6 +32,11 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base # This option is only valid for applications using Rack 2.0 or greater option :use_rack_events, default: true, validate: :boolean + def middleware_args + patch_type = determine_semconv + send(:"middleware_args_#{patch_type}") + end + # Temporary Helper for Sinatra and ActionPack middleware to use during installation # # @example Default usage @@ -40,7 +45,7 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base # run lambda { |_arg| [200, { 'Content-Type' => 'text/plain' }, body] } # end # @return [Array] consisting of a middleware and arguments used in rack builders - def middleware_args + def middleware_args_old if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler) [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Old::EventHandler.new]] else @@ -48,8 +53,6 @@ def middleware_args end end - alias middleware_args_old middleware_args - def middleware_args_dup if config.fetch(:use_rack_events, false) == true && defined?(OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler) [::Rack::Events, [OpenTelemetry::Instrumentation::Rack::Middlewares::Dup::EventHandler.new]] diff --git a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/version.rb b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/version.rb index f53023981c..bed6a41cb7 100644 --- a/instrumentation/rack/lib/opentelemetry/instrumentation/rack/version.rb +++ b/instrumentation/rack/lib/opentelemetry/instrumentation/rack/version.rb @@ -7,7 +7,7 @@ module OpenTelemetry module Instrumentation module Rack - VERSION = '0.28.1' + VERSION = '0.28.2' end end end diff --git a/instrumentation/sinatra/CHANGELOG.md b/instrumentation/sinatra/CHANGELOG.md index 24a31f4985..a01205a8cf 100644 --- a/instrumentation/sinatra/CHANGELOG.md +++ b/instrumentation/sinatra/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History: opentelemetry-instrumentation-sinatra +### v0.27.1 / 2025-10-07 + +* FIXED: Unify rack middleware_args + ### v0.27.0 / 2025-09-30 * ADDED: Bump minimum API Version to 1.7 diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb index b1ed53e7a7..bd7b85ad60 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/instrumentation.rb @@ -51,19 +51,7 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base end def install_middleware(app) - if config[:install_rack] - stability_opt_in = ENV.fetch('OTEL_SEMCONV_STABILITY_OPT_IN', '') - values = stability_opt_in.split(',').map(&:strip) - - if values.include?('http/dup') - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) - elsif values.include?('http') - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) - else - app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) - end - end - + app.use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) if config[:install_rack] app.use(Middlewares::TracerMiddleware) end end diff --git a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/version.rb b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/version.rb index ddbf6bda49..6538d1342a 100644 --- a/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/version.rb +++ b/instrumentation/sinatra/lib/opentelemetry/instrumentation/sinatra/version.rb @@ -7,7 +7,7 @@ module OpenTelemetry module Instrumentation module Sinatra - VERSION = '0.27.0' + VERSION = '0.27.1' end end end diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb index 332edc4762..bf75b7265a 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_dup_http_test.rb @@ -219,7 +219,7 @@ class CustomError < StandardError; end let(:app) do apps_to_build = apps Rack::Builder.new do - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_dup) + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) apps_to_build.each do |root, app| map root do diff --git a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb index 0d0313e395..150ab5d427 100644 --- a/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb +++ b/instrumentation/sinatra/test/opentelemetry/instrumentation/sinatra_stable_http_test.rb @@ -199,7 +199,7 @@ class CustomError < StandardError; end let(:app) do apps_to_build = apps Rack::Builder.new do - use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args_stable) + use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args) apps_to_build.each do |root, app| map root do diff --git a/sampler/xray/example/xray_sampling_on_rails_demonstration.ru b/sampler/xray/example/xray_sampling_on_rails_demonstration.ru new file mode 100644 index 0000000000..b3c203b3e0 --- /dev/null +++ b/sampler/xray/example/xray_sampling_on_rails_demonstration.ru @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'bundler/inline' + +gemfile(true) do + source 'https://rubygems.org' + + gem 'concurrent-ruby', '1.3.4' + gem 'rails', '~> 7.0.4' + gem 'puma' + + gem 'opentelemetry-sdk' + gem 'opentelemetry-instrumentation-rails' + gem 'opentelemetry-sampler-xray', path: './../' # Use local version of the X-Ray Sampler + # gem 'opentelemetry-sampler-xray' # Use RubyGems version of the X-Ray Sampler +end + +require "action_controller/railtie" +require "action_mailer/railtie" +require "rails/test_unit/railtie" + +class App < Rails::Application + config.root = __dir__ + config.consider_all_requests_local = true + + routes.append do + root to: 'welcome#index' + get "/test" => 'welcome#test' + end +end + +class WelcomeController < ActionController::Base + def index + render inline: 'Successfully called "/" endpoint' + end + + def test + render inline: 'Successfully called "/test" endpoint' + end +end + +ENV['OTEL_TRACES_EXPORTER'] ||= 'console' +ENV['OTEL_SERVICE_NAME'] ||= 'xray-sampler-on-rails-service' + +OpenTelemetry::SDK.configure do |c| + c.use_all +end + +OpenTelemetry.tracer_provider.sampler = OpenTelemetry::Sampler::XRay::AWSXRayRemoteSampler.new(resource:OpenTelemetry::SDK::Resources::Resource.create({ + "service.name"=>"xray-sampler-on-rails-service" +})) + +App.initialize! + +run App + +#### Running and using the Sample App +# To run this example run the `rackup` command with this file +# Example: rackup xray_sampling_on_rails_demonstration.ru +# Navigate to http://localhost:9292/ +# Spans for any requests sampled by the X-Ray Sampler will appear in the console + +#### Required configuration in the OpenTelemetry Collector +# In order for sampling rules to be obtained from AWS X-Ray, the awsproxy extension +# must be configured in the OpenTelemetry Collector, which will use your AWS credentials. +# - https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/awsproxy#aws-proxy +# Without the awsproxy extension, the X-Ray Sampler will use a fallback sampler +# with a sampling strategy of "1 request/second, plus 5% of any additional requests" + +#### Testing out configurable X-Ray Sampling Rules against the "service.name" resource attribute. +# Create a new Sampling Rule with the following matching criteria in AWS CloudWatch Settings for X-Ray Traces. +# - https://console.aws.amazon.com/cloudwatch/home#xray:settings/sampling-rules + # Matching Criteria + # ServiceName = xray-sampler-on-rails-service + # ServiceType = * + # Host = * + # ResourceARN = * + # HTTPMethod = * + # URLPath = * +# For the above matching criteria, try out the following settings to sample or not sample requests +# - Limit to 0r/sec then 0 fixed rate +# - Limit to 1r/sec then 0 fixed rate (May take 30 seconds for this setting to apply) +# - Limit to 0r/sec then 100% fixed rate + +#### Testing out configurable X-Ray Sampling Rules against the "/test" endpoint in this sample app. +# Create a new Sampling Rule with the following matching criteria in AWS CloudWatch Settings for X-Ray Traces. +# - https://console.aws.amazon.com/cloudwatch/home#xray:settings/sampling-rules + # Matching Criteria + # ServiceName = * + # ServiceType = * + # Host = * + # ResourceARN = * + # HTTPMethod = * + # URLPath = /test +# For the above matching criteria, try out the following settings to sample or not sample requests +# - Limit to 0r/sec then 0 fixed rate +# - Limit to 1r/sec then 0 fixed rate (May take 30 seconds for this setting to apply) +# - Limit to 0r/sec then 100% fixed rate \ No newline at end of file diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/aws_xray_remote_sampler.rb b/sampler/xray/lib/opentelemetry/sampler/xray/aws_xray_remote_sampler.rb index 41f051ca2f..3705f9c5ef 100644 --- a/sampler/xray/lib/opentelemetry/sampler/xray/aws_xray_remote_sampler.rb +++ b/sampler/xray/lib/opentelemetry/sampler/xray/aws_xray_remote_sampler.rb @@ -4,14 +4,13 @@ # # SPDX-License-Identifier: Apache-2.0 -require 'net/http' require 'json' require 'opentelemetry/sdk' -require_relative 'sampling_rule' +require_relative 'aws_xray_sampling_client' require_relative 'fallback_sampler' -require_relative 'sampling_rule_applier' require_relative 'rule_cache' -require_relative 'aws_xray_sampling_client' +require_relative 'sampling_rule' +require_relative 'sampling_rule_applier' module OpenTelemetry module Sampler @@ -68,7 +67,8 @@ def initialize(endpoint: '127.0.0.1:2000', polling_interval: DEFAULT_RULES_POLLI # Start the Sampling Rules poller start_sampling_rules_poller - # TODO: Start the Sampling Targets poller + # Start the Sampling Targets poller + start_sampling_targets_poller end def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:) @@ -113,6 +113,15 @@ def start_sampling_rules_poller end end + def start_sampling_targets_poller + @target_poller = Thread.new do + loop do + sleep(((@target_polling_interval * 1000) + @target_polling_jitter_millis) / 1000.0) + retrieve_and_update_sampling_targets + end + end + end + def retrieve_and_update_sampling_rules sampling_rules_response = @sampling_client.fetch_sampling_rules if sampling_rules_response&.body && sampling_rules_response.body != '' @@ -125,6 +134,19 @@ def retrieve_and_update_sampling_rules OpenTelemetry.handle_error(exception: e, message: 'Error occurred when retrieving or updating Sampling Rules') end + def retrieve_and_update_sampling_targets + request_body = { + SamplingStatisticsDocuments: @rule_cache.create_sampling_statistics_documents(@client_id) + } + sampling_targets_response = @sampling_client.fetch_sampling_targets(request_body) + if sampling_targets_response&.body && sampling_targets_response.body != '' + response_body = JSON.parse(sampling_targets_response.body) + update_sampling_targets(response_body) + else + OpenTelemetry.logger.debug('SamplingTargets Response is falsy') + end + end + def update_sampling_rules(response_object) sampling_rules = [] if response_object && response_object['SamplingRuleRecords'] @@ -140,6 +162,33 @@ def update_sampling_rules(response_object) end end + def update_sampling_targets(response_object) + if response_object && response_object['SamplingTargetDocuments'] + target_documents = {} + + response_object['SamplingTargetDocuments'].each do |new_target| + target_documents[new_target['RuleName']] = new_target + end + + refresh_sampling_rules, next_polling_interval = @rule_cache.update_targets( + target_documents, + response_object['LastRuleModification'] + ) + + @target_polling_interval = next_polling_interval + + if refresh_sampling_rules + OpenTelemetry.logger.debug('Performing out-of-band sampling rule polling to fetch updated rules.') + @rule_poller&.kill + start_sampling_rules_poller + end + else + OpenTelemetry.logger.debug('SamplingTargetDocuments from SamplingTargets request is not defined') + end + rescue StandardError => e + OpenTelemetry.logger.debug("Error occurred when updating Sampling Targets: #{e}") + end + class << self def generate_client_id hex_chars = ('0'..'9').to_a + ('a'..'f').to_a diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/fallback_sampler.rb b/sampler/xray/lib/opentelemetry/sampler/xray/fallback_sampler.rb index f25b08052c..75174150a8 100644 --- a/sampler/xray/lib/opentelemetry/sampler/xray/fallback_sampler.rb +++ b/sampler/xray/lib/opentelemetry/sampler/xray/fallback_sampler.rb @@ -4,6 +4,8 @@ # # SPDX-License-Identifier: Apache-2.0 +require_relative 'rate_limiting_sampler' + module OpenTelemetry module Sampler module XRay @@ -11,10 +13,15 @@ module XRay class FallbackSampler def initialize @fixed_rate_sampler = OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(0.05) + @rate_limiting_sampler = RateLimitingSampler.new(1) end def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:) - # TODO: implement and use Rate Limiting Sampler + sampling_result = @rate_limiting_sampler.should_sample?( + trace_id: trace_id, parent_context: parent_context, links: links, name: name, kind: kind, attributes: attributes + ) + + return sampling_result if sampling_result.instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP @fixed_rate_sampler.should_sample?(trace_id: trace_id, parent_context: parent_context, links: links, name: name, kind: kind, attributes: attributes) end diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiter.rb b/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiter.rb new file mode 100644 index 0000000000..6baee1dc0a --- /dev/null +++ b/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampler + module XRay + # RateLimiter keeps track of the current reservoir quota balance available (measured via available time) + # If enough time has elapsed, the RateLimiter will allow quota balance to be consumed/taken (decrease available time) + # A RateLimitingSampler uses this RateLimiter to determine if it should sample or not based on the quota balance available. + class RateLimiter + def initialize(quota, max_balance_in_seconds = 1) + @max_balance_millis = max_balance_in_seconds * 1000.0 + @quota = quota + @wallet_floor_millis = Time.now.to_f * 1000 + # current "balance" would be `ceiling - floor` + @lock = Mutex.new + end + + def take(cost = 1) + return false if @quota <= 0 + + quota_per_millis = @quota / 1000.0 + + # assume divide by zero not possible + cost_in_millis = cost / quota_per_millis + + @lock.synchronize do + wallet_ceiling_millis = Time.now.to_f * 1000 + current_balance_millis = wallet_ceiling_millis - @wallet_floor_millis + current_balance_millis = [current_balance_millis, @max_balance_millis].min + pending_remaining_balance_millis = current_balance_millis - cost_in_millis + + if pending_remaining_balance_millis >= 0 + @wallet_floor_millis = wallet_ceiling_millis - pending_remaining_balance_millis + return true + end + + # No changes to the wallet state + false + end + end + end + end + end +end diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiting_sampler.rb b/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiting_sampler.rb new file mode 100644 index 0000000000..d5dd093b4f --- /dev/null +++ b/sampler/xray/lib/opentelemetry/sampler/xray/rate_limiting_sampler.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative 'rate_limiter' + +module OpenTelemetry + module Sampler + module XRay + # RateLimitingSampler is a Sampler that uses a RateLimiter to determine + # if it should sample or not based on the quota balance available. + class RateLimitingSampler + def initialize(quota) + @quota = quota + @reservoir = RateLimiter.new(quota) + end + + def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:) + tracestate = OpenTelemetry::Trace.current_span(parent_context).context.tracestate + if @reservoir.take(1) + OpenTelemetry::SDK::Trace::Samplers::Result.new( + decision: OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + tracestate: tracestate, + attributes: attributes + ) + else + OpenTelemetry::SDK::Trace::Samplers::Result.new( + decision: OpenTelemetry::SDK::Trace::Samplers::Decision::DROP, + tracestate: tracestate, + attributes: attributes + ) + end + end + + def to_s + "RateLimitingSampler{rate limiting sampling with sampling config of #{@quota} req/sec and 0% of additional requests}" + end + end + end + end +end diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/rule_cache.rb b/sampler/xray/lib/opentelemetry/sampler/xray/rule_cache.rb index 4583fe8397..b1b13f5b41 100644 --- a/sampler/xray/lib/opentelemetry/sampler/xray/rule_cache.rb +++ b/sampler/xray/lib/opentelemetry/sampler/xray/rule_cache.rb @@ -53,6 +53,52 @@ def update_rules(new_rule_appliers) end end + def create_sampling_statistics_documents(client_id) + statistics_documents = [] + + @cache_lock.synchronize do + @rule_appliers.each do |rule| + statistics = rule.snapshot_statistics + now_in_seconds = Time.now.to_i + + sampling_statistics_doc = { + ClientID: client_id, + RuleName: rule.sampling_rule.rule_name, + Timestamp: now_in_seconds, + RequestCount: statistics.request_count, + BorrowCount: statistics.borrow_count, + SampledCount: statistics.sample_count + } + + statistics_documents << sampling_statistics_doc + end + end + + statistics_documents + end + + def update_targets(target_documents, last_rule_modification) + min_polling_interval = nil + next_polling_interval = DEFAULT_TARGET_POLLING_INTERVAL_SECONDS + + @cache_lock.synchronize do + @rule_appliers.each_with_index do |rule, index| + target = target_documents[rule.sampling_rule.rule_name] + if target + @rule_appliers[index] = rule.with_target(target) + min_polling_interval = target['Interval'] if target['Interval'] && (min_polling_interval.nil? || min_polling_interval > target['Interval']) + else + OpenTelemetry.logger.debug('Invalid sampling target: missing rule name') + end + end + + next_polling_interval = min_polling_interval if min_polling_interval + + refresh_sampling_rules = last_rule_modification * 1000 > @last_updated_epoch_millis + return [refresh_sampling_rules, next_polling_interval] + end + end + private def sort_rules_by_priority diff --git a/sampler/xray/lib/opentelemetry/sampler/xray/sampling_rule_applier.rb b/sampler/xray/lib/opentelemetry/sampler/xray/sampling_rule_applier.rb index 0863d738c5..7ace9a6aa0 100644 --- a/sampler/xray/lib/opentelemetry/sampler/xray/sampling_rule_applier.rb +++ b/sampler/xray/lib/opentelemetry/sampler/xray/sampling_rule_applier.rb @@ -9,6 +9,7 @@ require 'date' require_relative 'sampling_rule' require_relative 'statistics' +require_relative 'rate_limiting_sampler' require_relative 'utils' module OpenTelemetry @@ -26,10 +27,24 @@ def initialize(sampling_rule, statistics = OpenTelemetry::Sampler::XRay::Statist @sampling_rule = sampling_rule @fixed_rate_sampler = OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(@sampling_rule.fixed_rate) - # TODO: Add Reservoir Sampler (Rate Limiting Sampler) + @reservoir_sampler = if @sampling_rule.reservoir_size.positive? + OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(1) + else + OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(0) + end @reservoir_expiry_time = MAX_DATE_TIME_SECONDS @statistics = statistics + @statistics_lock = Mutex.new + + @statistics.reset_statistics + @borrowing_enabled = true + + apply_target(target) if target + end + + def with_target(target) + self.class.new(@sampling_rule, @statistics, target) end def matches?(attributes, resource) @@ -78,14 +93,21 @@ def matches?(attributes, resource) end def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:) - # TODO: Record Sampling Statistics - + has_borrowed = false result = OpenTelemetry::SDK::Trace::Samplers::Result.new( decision: OpenTelemetry::SDK::Trace::Samplers::Decision::DROP, tracestate: OpenTelemetry::Trace::Tracestate::DEFAULT ) - # TODO: Apply Reservoir Sampling + now = Time.now + reservoir_expired = now >= @reservoir_expiry_time + + unless reservoir_expired + result = @reservoir_sampler.should_sample?( + trace_id: trace_id, parent_context: parent_context, links: links, name: name, kind: kind, attributes: attributes + ) + has_borrowed = @borrowing_enabled && result.instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + end if result.instance_variable_get(:@decision) == OpenTelemetry::SDK::Trace::Samplers::Decision::DROP result = @fixed_rate_sampler.should_sample?( @@ -93,11 +115,41 @@ def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes: ) end + @statistics_lock.synchronize do + @statistics.sample_count += result.instance_variable_get(:@decision) == OpenTelemetry::SDK::Trace::Samplers::Decision::DROP ? 0 : 1 + @statistics.borrow_count += has_borrowed ? 1 : 0 + @statistics.request_count += 1 + end + result end + def snapshot_statistics + @statistics_lock.synchronize do + statistics_copy = @statistics.dup + @statistics.reset_statistics + return statistics_copy + end + end + private + def apply_target(target) + @borrowing_enabled = false + + @reservoir_sampler = OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(target['ReservoirQuota']) if target['ReservoirQuota'] + + @reservoir_expiry_time = if target['ReservoirQuotaTTL'] + Time.at(target['ReservoirQuotaTTL']) + else + Time.now + end + + return unless target['FixedRate'] + + @fixed_rate_sampler = OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(target['FixedRate']) + end + def get_arn(resource, attributes) resource_hash = resource.attribute_enumerator.to_h arn = resource_hash[SEMCONV::Resource::AWS_ECS_CONTAINER_ARN] || diff --git a/sampler/xray/test/aws_xray_remote_sampler_test.rb b/sampler/xray/test/aws_xray_remote_sampler_test.rb index 3de4299cb5..e745b27a5c 100644 --- a/sampler/xray/test/aws_xray_remote_sampler_test.rb +++ b/sampler/xray/test/aws_xray_remote_sampler_test.rb @@ -91,7 +91,14 @@ assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::DROP, rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) - # TODO: Run more tests after updating Sampling Targets + rs.instance_variable_get(:@root).instance_variable_get(:@root).send(:retrieve_and_update_sampling_targets) + + assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) + assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) + assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) end it 'generates valid client id' do @@ -119,5 +126,88 @@ def create_spans(sampled_array, thread_id, span_attributes, remote_sampler, numb sampled_array[thread_id] = sampled end - # TODO: Run tests for Reservoir Sampling + it 'test_multithreading_with_large_reservoir' do + stub_request(:post, "http://#{TEST_URL}/GetSamplingRules") + .to_return(status: 200, body: File.read(DATA_DIR_SAMPLING_RULES)) + stub_request(:post, "http://#{TEST_URL}/SamplingTargets") + .to_return(status: 200, body: File.read(DATA_DIR_SAMPLING_TARGETS)) + + rs = OpenTelemetry::Sampler::XRay::AWSXRayRemoteSampler.new( + resource: OpenTelemetry::SDK::Resources::Resource.create({ + 'service.name' => 'test-service-name', + 'cloud.platform' => 'test-cloud-platform' + }) + ) + + attributes = { 'abc' => '1234' } + assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::DROP, + rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) + rs.instance_variable_get(:@root).instance_variable_get(:@root).send(:retrieve_and_update_sampling_targets) + + number_of_spans = 100 + thread_count = 100 + sampled_array = Array.new(thread_count, 0) + threads = [] + + thread_count.times do |idx| + threads << Thread.new do + create_spans(sampled_array, idx, attributes, rs, number_of_spans) + end + end + + threads.each(&:join) + sum_sampled = sampled_array.sum + + test_rule_applier = rs.instance_variable_get(:@root).instance_variable_get(:@root).instance_variable_get(:@rule_cache).instance_variable_get(:@rule_appliers)[0] + assert_equal 100_000, test_rule_applier.instance_variable_get(:@reservoir_sampler).instance_variable_get(:@quota) + assert_equal 10_000, sum_sampled + end + + it 'test_multithreading_with_some_reservoir' do + stub_request(:post, "http://#{TEST_URL}/GetSamplingRules") + .to_return(status: 200, body: File.read(DATA_DIR_SAMPLING_RULES)) + stub_request(:post, "http://#{TEST_URL}/SamplingTargets") + .to_return(status: 200, body: File.read(DATA_DIR_SAMPLING_TARGETS)) + + rs = OpenTelemetry::Sampler::XRay::AWSXRayRemoteSampler.new( + resource: OpenTelemetry::SDK::Resources::Resource.create({ + 'service.name' => 'test-service-name', + 'cloud.platform' => 'test-cloud-platform' + }) + ) + + attributes = { 'abc' => 'non-matching attribute value, use default rule' } + assert_equal OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + rs.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: attributes, links: []).instance_variable_get(:@decision) + + rs.instance_variable_get(:@root).instance_variable_get(:@root).send(:retrieve_and_update_sampling_targets) + + # Freeze time 1.5 seconds later in the future, but there should only be 1 second worth + # of reservoir available, which amounts to 100 sampled spans in this test. + # Here we will freeze time and pretend all thread jobs start and end at the exact same time, + # given exactly 1 second of available reservoir (100 quota) only. + current_time = Time.now + Timecop.freeze(current_time + 1.5) + + number_of_spans = 100 + thread_count = 100 + sampled_array = Array.new(thread_count, 0) + threads = [] + + thread_count.times do |idx| + threads << Thread.new do + create_spans(sampled_array, idx, attributes, rs, number_of_spans) + end + end + + threads.each(&:join) + sum_sampled = sampled_array.sum + + test_rule_applier = rs.instance_variable_get(:@root).instance_variable_get(:@root).instance_variable_get(:@rule_cache).instance_variable_get(:@rule_appliers)[1] + assert_equal 100, test_rule_applier.instance_variable_get(:@reservoir_sampler).instance_variable_get(:@quota) + assert_equal 100, sum_sampled + + # Return to normal time. + Timecop.return + end end diff --git a/sampler/xray/test/fallback_sampler_test.rb b/sampler/xray/test/fallback_sampler_test.rb index 01f5224b71..a1282fb0d6 100644 --- a/sampler/xray/test/fallback_sampler_test.rb +++ b/sampler/xray/test/fallback_sampler_test.rb @@ -7,7 +7,116 @@ require 'test_helper' describe OpenTelemetry::Sampler::XRay::FallbackSampler do - # TODO: Add tests for Fallback sampler when Rate Limiter is implemented + before do + # Freeze time at the current moment + @current_time = Time.now + end + + after do + # Return to normal time + Timecop.return + end + + it 'test_should_sample' do + Timecop.freeze(@current_time) + sampler = OpenTelemetry::Sampler::XRay::FallbackSampler.new + + sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, links: []) + + # 0 seconds passed, 0 quota available + sampled = 0 + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 0, sampled + + # 0.4 seconds passed, 0.4 quota available + sampled = 0 + @current_time += 0.4 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 0, sampled + + # Another 0.8 seconds passed, 1 quota available (1.2 quota capped at 1 quota), 1 quota consumed + sampled = 0 + @current_time += 0.8 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 1, sampled + + # Another 1.9 seconds passed, 1 quota available (1.9 quota capped at 1 quota), 1 quota consumed + sampled = 0 + @current_time += 1.9 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 1, sampled + + # Another 0.9 seconds passed, 0.9 quota available, 0 quota consumed + sampled = 0 + @current_time += 0.9 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 0, sampled + + # Another 2.0 seconds passed, 1 quota available (2.0 quota capped at 1 quota), 1 quota consumed + sampled = 0 + @current_time += 2.0 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 1, sampled + + # Another 2.4 seconds passed, 1 quota available (2.4 quota capped at 1 quota), 1 quota consumed + sampled = 0 + @current_time += 2.4 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 1, sampled + + # Another 100 seconds passed, 1 quota available (100 quota capped at 1 quota), 1 quota consumed + sampled = 0 + @current_time += 100 + Timecop.freeze(@current_time) + 30.times do + if sampler.should_sample?(parent_context: nil, trace_id: '3759e988bd862e3fe1be46a994272793', name: 'name', kind: OpenTelemetry::Trace::SpanKind::SERVER, attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + sampled += 1 + end + end + assert_equal 1, sampled + end it 'test_to_string' do assert_equal( diff --git a/sampler/xray/test/rate_limiter_test.rb b/sampler/xray/test/rate_limiter_test.rb new file mode 100644 index 0000000000..51d3266fa5 --- /dev/null +++ b/sampler/xray/test/rate_limiter_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' +require 'timecop' + +describe OpenTelemetry::Sampler::XRay::RateLimiter do + before do + # Freeze time at the current moment + @current_time = Time.now + end + + after do + # Return to normal time + Timecop.return + end + + it 'test_take' do + Timecop.freeze(@current_time) + limiter = OpenTelemetry::Sampler::XRay::RateLimiter.new(30, 1) + + # First batch - no quota is available + spent = 0 + 100.times do + spent += 1 if limiter.take(1) + end + assert_equal 0, spent + + # Second batch - should get half the available quota after 0.5 seconds + @current_time += 0.5 + Timecop.freeze(@current_time) + spent = 0 + 100.times do + spent += 1 if limiter.take(1) + end + assert_equal 15, spent + + # Third batch - should get all the available quota after 1 second + @current_time += 1 + Timecop.freeze(@current_time) + spent = 0 + 100.times do + spent += 1 if limiter.take(1) + end + assert_equal 30, spent + end + + it 'test_take_with_zero_quota' do + limiter = OpenTelemetry::Sampler::XRay::RateLimiter.new(0, 1) + + # Zero quota should always return false + refute limiter.take(1) + end + + it 'test_take_with_negative_quota' do + limiter = OpenTelemetry::Sampler::XRay::RateLimiter.new(-5, 1) + + # Negative quota should always return false + refute limiter.take(1) + end +end diff --git a/sampler/xray/test/rate_limiting_sampler_test.rb b/sampler/xray/test/rate_limiting_sampler_test.rb new file mode 100644 index 0000000000..b24bc862f1 --- /dev/null +++ b/sampler/xray/test/rate_limiting_sampler_test.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::Sampler::XRay::RateLimitingSampler do + before do + # Freeze time at the current moment + @current_time = Time.now + Timecop.freeze(@current_time) + end + + after do + # Return to normal time + Timecop.return + end + + it 'test_should_sample' do + sampler = OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(30) + + sampled = 0 + 100.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 0, sampled + + @current_time += 0.5 + Timecop.freeze(@current_time) # Move forward half a second + + sampled = 0 + 100.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 15, sampled + + @current_time += 1 + Timecop.freeze(@current_time) # Move forward 1 second + + sampled = 0 + 100.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 30, sampled + + @current_time += 2.5 + Timecop.freeze(@current_time) # Move forward 2.5 more seconds + + sampled = 0 + 100.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 30, sampled + + @current_time += 1000 + Timecop.freeze(@current_time) # Move forward 1000 seconds + + sampled = 0 + 100.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 30, sampled + end + + it 'test_should_sample_with_quota_of_one' do + sampler = OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(1) + + sampled = 0 + 50.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 0, sampled + + @current_time += 0.5 + Timecop.freeze(@current_time) # Move forward half a second + + sampled = 0 + 50.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 0, sampled + + @current_time += 0.5 + Timecop.freeze(@current_time) # Move forward another half second + + sampled = 0 + 50.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 1, sampled + + @current_time += 1000 + Timecop.freeze(@current_time) # Move forward 1000 seconds + + sampled = 0 + 50.times do + next unless sampler.should_sample?(parent_context: nil, + trace_id: '3759e988bd862e3fe1be46a994272793', + name: 'name', + kind: OpenTelemetry::Trace::SpanKind::SERVER, + attributes: {}, + links: []).instance_variable_get(:@decision) != OpenTelemetry::SDK::Trace::Samplers::Decision::DROP + + sampled += 1 + end + assert_equal 1, sampled + end + + it 'test_to_string' do + assert_equal( + 'RateLimitingSampler{rate limiting sampling with sampling config of 123 req/sec and 0% of additional requests}', + OpenTelemetry::Sampler::XRay::RateLimitingSampler.new(123).to_s + ) + end +end diff --git a/sampler/xray/test/rule_cache_test.rb b/sampler/xray/test/rule_cache_test.rb index bad80e3fc0..cc7ac1c1fa 100644 --- a/sampler/xray/test/rule_cache_test.rb +++ b/sampler/xray/test/rule_cache_test.rb @@ -24,6 +24,11 @@ def create_rule(name, priority, reservoir_size, fixed_rate) OpenTelemetry::Sampler::XRay::SamplingRuleApplier.new(OpenTelemetry::Sampler::XRay::SamplingRule.new(test_sampling_rule)) end + after do + # Return to normal time + Timecop.return + end + it 'test_cache_updates_and_sorts_rules' do # Set up default rule in rule cache default_rule = create_rule('Default', 10_000, 1, 0.05) @@ -55,12 +60,13 @@ def create_rule(name, priority, reservoir_size, fixed_rate) end it 'test_rule_cache_expiration_logic' do - Timecop.freeze(Time.now) do + current_time = Time.now + Timecop.freeze(current_time) do default_rule = create_rule('Default', 10_000, 1, 0.05) cache = OpenTelemetry::Sampler::XRay::RuleCache.new(OpenTelemetry::SDK::Resources::Resource.create({})) cache.update_rules([default_rule]) - Timecop.travel(2 * 60 * 60) # Travel 2 hours into the future + Timecop.freeze(current_time + (2 * 60 * 60)) # Travel 2 hours into the future assert cache.expired? end end @@ -109,5 +115,86 @@ def create_rule(name, priority, reservoir_size, fixed_rate) assert_equal 'second_rule', rule_appliers[0].sampling_rule.rule_name end - # TODO: Add tests for updating Sampling Targets and getting statistics + it 'test_update_sampling_targets' do + rule1 = create_rule('default', 10_000, 1, 0.05) + rule2 = create_rule('test', 20, 10, 0.2) + cache = OpenTelemetry::Sampler::XRay::RuleCache.new(OpenTelemetry::SDK::Resources::Resource.create({})) + cache.update_rules([rule1, rule2]) + + time = Time.now.to_i + target1 = { + 'FixedRate' => 0.05, + 'Interval' => 15, + 'ReservoirQuota' => 1, + 'ReservoirQuotaTTL' => time + 10, + 'RuleName' => 'default' + } + target2 = { + 'FixedRate' => 0.15, + 'Interval' => 12, + 'ReservoirQuota' => 5, + 'ReservoirQuotaTTL' => time + 10, + 'RuleName' => 'test' + } + target3 = { + 'FixedRate' => 0.15, + 'Interval' => 3, + 'ReservoirQuota' => 5, + 'ReservoirQuotaTTL' => time + 10, + 'RuleName' => 'associated rule does not exist' + } + + target_map = { + 'default' => target1, + 'test' => target2, + 'associated rule does not exist' => target3 + } + + refresh_sampling_rules, next_polling_interval = cache.update_targets(target_map, time - 10) + refute refresh_sampling_rules + assert_equal target2['Interval'], next_polling_interval + + rule_appliers = cache.instance_variable_get(:@rule_appliers) + assert_equal 2, rule_appliers.length + + refresh_sampling_rules_after, = cache.update_targets(target_map, time + 1) + assert refresh_sampling_rules_after + end + + it 'test_get_all_statistics' do + current_time = Time.now + Timecop.freeze(current_time) do + rule1 = create_rule('test', 4, 2, 2.0) + rule2 = create_rule('default', 5, 5, 5.0) + + cache = OpenTelemetry::Sampler::XRay::RuleCache.new(OpenTelemetry::SDK::Resources::Resource.create) + cache.update_rules([rule1, rule2]) + + Timecop.freeze(current_time + 0.001) # Travel 1ms into the future + + client_id = '12345678901234567890abcd' + statistics = cache.create_sampling_statistics_documents(client_id) + + expected_statistics = [ + { + ClientID: client_id, + RuleName: 'test', + Timestamp: Time.now.to_i, + RequestCount: 0, + BorrowCount: 0, + SampledCount: 0 + }, + { + ClientID: client_id, + RuleName: 'default', + Timestamp: Time.now.to_i, + RequestCount: 0, + BorrowCount: 0, + SampledCount: 0 + } + ] + + assert_equal expected_statistics, statistics + end + end end