diff --git a/sampling/xray/.rubocop.yml b/sampling/xray/.rubocop.yml new file mode 100644 index 000000000..1248a2f82 --- /dev/null +++ b/sampling/xray/.rubocop.yml @@ -0,0 +1 @@ +inherit_from: ../../.rubocop.yml diff --git a/sampling/xray/.yardopts b/sampling/xray/.yardopts new file mode 100644 index 000000000..9e3ee9543 --- /dev/null +++ b/sampling/xray/.yardopts @@ -0,0 +1,9 @@ +--no-private +--title=OpenTelemetry XRay Remote Sampling +--markup=markdown +--main=README.md +./lib/opentelemetry/sampling/xray/**/*.rb +./lib/opentelemetry/sampling/xray.rb +- +README.md +CHANGELOG.md diff --git a/sampling/xray/CHANGELOG.md b/sampling/xray/CHANGELOG.md new file mode 100644 index 000000000..6b0fe9ea1 --- /dev/null +++ b/sampling/xray/CHANGELOG.md @@ -0,0 +1 @@ +# Release History: opentelemetry-sampling-xray diff --git a/sampling/xray/Gemfile b/sampling/xray/Gemfile new file mode 100644 index 000000000..892a9c92b --- /dev/null +++ b/sampling/xray/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +source('https://rubygems.org') + +gemspec diff --git a/sampling/xray/LICENSE b/sampling/xray/LICENSE new file mode 100644 index 000000000..ada534dc9 --- /dev/null +++ b/sampling/xray/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 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/sampling/xray/README.md b/sampling/xray/README.md new file mode 100644 index 000000000..cfac55482 --- /dev/null +++ b/sampling/xray/README.md @@ -0,0 +1,49 @@ +# opentelemetry-sampling-xray + +The `opentelemetry-sampling-xray` gem allows for using [X-Ray remote sampling](https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-sampling). + +## What is OpenTelemetry? + +[OpenTelemetry][opentelemetry-home] 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. + +OpenTelemetry provides a single set of APIs, libraries, agents, and collector services to capture distributed traces and metrics from your application. You can analyze them using Prometheus, Jaeger, and other observability tools. + +## How does this gem fit in? + +This gem can be used with any OpenTelemetry SDK implementation. This can be the official `opentelemetry-sdk` gem or any other concrete implementation. + +## How do I get started? + +Install the gem using: + +``` +gem install opentelemetry-sampling-xray +``` + +Or, if you use [bundler][bundler-home], include `opentelemetry-sampling-xray` in your `Gemfile`. + +In your application: +``` +require 'opentelemetry/sampling/xray' +# Optional +ENV['OTEL_TRACES_SAMPLER'] ||= 'xray' # Or you can set this as an environment variable outside of the application +``` + +## How can I get involved? + +The `opentelemetry-sampling-xray` gem source is [on github][repo-github], along with related gems including `opentelemetry-api` and `opentelemetry-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-sampling-xray` gem is distributed under the Apache 2.0 license. See [LICENSE][license-github] for more information. + +[opentelemetry-home]: https://opentelemetry.io +[bundler-home]: https://bundler.io +[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 +[aws-xray]: https://docs.aws.amazon.com/xray/latest/devguide/aws-xray.html diff --git a/sampling/xray/Rakefile b/sampling/xray/Rakefile new file mode 100644 index 000000000..0f8e8ac8d --- /dev/null +++ b/sampling/xray/Rakefile @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright 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 |task| + task.libs << 'test' + task.libs << 'lib' + task.test_files = FileList['test/**/*_test.rb'] +end + +YARD::Rake::YardocTask.new do |task| + task.stats_options = ['--list-undoc'] +end + +if RUBY_ENGINE == 'truffleruby' + task(default: %i[test]) +else + task(default: %i[test rubocop yard]) +end diff --git a/sampling/xray/lib/opentelemetry-sampling-xray.rb b/sampling/xray/lib/opentelemetry-sampling-xray.rb new file mode 100644 index 000000000..2a9100ede --- /dev/null +++ b/sampling/xray/lib/opentelemetry-sampling-xray.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('opentelemetry-api') +require_relative('opentelemetry/sampling/xray') diff --git a/sampling/xray/lib/opentelemetry/sampling/xray.rb b/sampling/xray/lib/opentelemetry/sampling/xray.rb new file mode 100644 index 000000000..b52a9724f --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Copyright 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. + +require_relative('xray/sampler') +require_relative('xray/version') diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/cache.rb b/sampling/xray/lib/opentelemetry/sampling/xray/cache.rb new file mode 100644 index 000000000..f3dfdf1d5 --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/cache.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative('sampling_rule') + +module OpenTelemetry + module Sampling + module XRay + class Cache + def initialize + @rules = [] + @lock = Mutex.new + end + + # @param [Hash] attributes + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @return [SamplingRule] + def get_first_matching_rule(attributes:, resource:) + @lock.synchronize do + @rules.find { |rule| rule.match?(resource: resource, attributes: attributes) } + end + end + + # @return [Array] + def get_matched_rules + @rules.select(&:ever_matched?) + end + + # @param [Array] rules + def update_rules(rules) + sorted_rules = rules.sort_by { |rule| [rule.priority, rule.rule_name] } + + @lock.synchronize do + current_rules = @rules.to_h { |rule| [rule.rule_name, rule] } + @rules = sorted_rules + + @rules.each { |rule| rule.merge(current_rules[rule.rule_name]) } + end + + OpenTelemetry.logger.debug("Updated sampling rules: #{@rules}") + end + + # @param [Array] targets + def update_targets(targets) + name_to_target = targets.to_h { |target| [target.rule_name, target] } + + @lock.synchronize do + @rules.each { |rule| rule.with_target(name_to_target[rule.rule_name]) } + end + + OpenTelemetry.logger.debug("Updated sampling targets: #{@rules}") + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/client.rb b/sampling/xray/lib/opentelemetry/sampling/xray/client.rb new file mode 100644 index 000000000..b484b9cad --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/client.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('json') +require('net/http') +require('time') +require_relative('sampling_rule') + +module OpenTelemetry + module Sampling + module XRay + class Client + # @param [String] endpoint + def initialize(endpoint:) + @endpoint = endpoint + @client_id = SecureRandom.hex(12) + OpenTelemetry.logger.info("Initialized X-Ray client with endpoint '#{@endpoint}' and client ID '#{@client_id}'") + end + + # @return [Array] + def fetch_sampling_rules + post(path: '/GetSamplingRules') + .fetch(:SamplingRuleRecords, []) + .map { |item| SamplingRuleRecord.from_response(item) } + end + + # @param [Array] sampling_rules + # @return [SamplingTargetResponse] + def fetch_sampling_targets(sampling_rules) + OpenTelemetry.logger.debug("Fetching sampling targets for rules: #{sampling_rules}") + + response = post( + path: '/SamplingTargets', + body: { + SamplingStatisticsDocuments: sampling_rules.map { |rule| SamplingStatisticsDocument.from_rule(rule, @client_id) }.map(&:to_request) + } + ) + SamplingTargetResponse.from_response(response) + end + + private + + # @param [String] path + # @param [Hash] body + # @return [Hash] + def post(path:, body: nil) + response = Net::HTTP.post(URI("#{@endpoint}#{path}"), body&.to_json) + + raise("Received #{response.code} (#{response.message}): #{response.body}") unless response.is_a?(Net::HTTPSuccess) + + OpenTelemetry.logger.debug("X-Ray response for #{path}: #{response.body}") + JSON.parse(response.body, symbolize_names: true) + rescue StandardError => e + OpenTelemetry.logger.error("Error while posting to X-Ray: #{e.message}") + {} + end + + class SamplingRuleRecord + attr_reader(:sampling_rule) + + # @param [SamplingRule] sampling_rule + # @param [Time] created_at + # @param [Time] modified_at + def initialize(sampling_rule:, created_at:, modified_at:) + @sampling_rule = sampling_rule + @created_at = created_at + @modified_at = modified_at + end + + # @param [Hash] response + # @return [SamplingRuleRecord] + def self.from_response(response) + SamplingRuleRecord.new( + sampling_rule: SamplingRule.new( + attributes: response[:SamplingRule][:Attributes], + fixed_rate: response[:SamplingRule][:FixedRate], + host: response[:SamplingRule][:Host], + http_method: response[:SamplingRule][:HTTPMethod], + priority: response[:SamplingRule][:Priority], + reservoir_size: response[:SamplingRule][:ReservoirSize], + resource_arn: response[:SamplingRule][:ResourceARN], + rule_arn: response[:SamplingRule][:RuleARN], + rule_name: response[:SamplingRule][:RuleName], + service_name: response[:SamplingRule][:ServiceName], + service_type: response[:SamplingRule][:ServiceType], + url_path: response[:SamplingRule][:URLPath], + version: response[:SamplingRule][:Version] + ), + created_at: Time.at(response[:CreatedAt]), + modified_at: Time.at(response[:ModifiedAt]) + ) + end + end + + class SamplingStatisticsDocument + # @param [String] rule_name + # @param [String] client_id + # @param [Time] timestamp + # @param [Integer] request_count + # @param [Integer] sampled_count + # @param [Integer] borrow_count + def initialize( + rule_name:, + client_id:, + timestamp:, + request_count:, + sampled_count:, + borrow_count: + ) + @rule_name = rule_name + @client_id = client_id + @timestamp = timestamp + @request_count = request_count + @sampled_count = sampled_count + @borrow_count = borrow_count + end + + # @return [Hash] + def to_request + { + RuleName: @rule_name, + ClientID: @client_id, + Timestamp: @timestamp.to_i, + RequestCount: @request_count, + SampledCount: @sampled_count, + BorrowCount: @borrow_count + } + end + + # @param [Object] other + # @return [Boolean] + def ==(other) + other.is_a?(SamplingStatisticsDocument) && to_request == other.to_request + end + + # @param [SamplingRule] rule + # @param [String] client_id + # @return [SamplingStatisticsDocument] + def self.from_rule(rule, client_id) + statistic = rule.snapshot_statistic + + SamplingStatisticsDocument.new( + rule_name: rule.rule_name, + client_id: client_id, + timestamp: Time.now, + request_count: statistic.request_count, + sampled_count: statistic.sampled_count, + borrow_count: statistic.borrow_count + ) + end + end + + class SamplingTargetDocument + attr_reader( + :rule_name, + :fixed_rate, + :reservoir_quota, + :reservoir_quota_ttl, + :interval + ) + + # @param [String] rule_name + # @param [Float] fixed_rate + # @param [Integer] reservoir_quota + # @param [Integer] reservoir_quota_ttl + # @param [Integer] interval + def initialize( + rule_name:, + fixed_rate:, + reservoir_quota:, + reservoir_quota_ttl:, + interval: + ) + @rule_name = rule_name + @fixed_rate = fixed_rate + @reservoir_quota = reservoir_quota + @reservoir_quota_ttl = reservoir_quota_ttl + @interval = interval + end + + # @param [Hash] response + # @return [SamplingTargetDocument] + def self.from_response(response) + SamplingTargetDocument.new( + rule_name: response[:RuleName], + fixed_rate: response[:FixedRate], + reservoir_quota: response[:ReservoirQuota], + reservoir_quota_ttl: response[:ReservoirQuotaTTL], + interval: response[:Interval] + ) + end + end + + class SamplingTargetResponse + attr_reader(:last_rule_modification, :sampling_target_documents) + + def initialize(last_rule_modification:, sampling_target_documents:) + @last_rule_modification = last_rule_modification + @sampling_target_documents = sampling_target_documents + end + + # @param [Hash] response + # @return [SamplingTargetResponse] + def self.from_response(response) + return nil if response.empty? + + SamplingTargetResponse.new( + last_rule_modification: Time.at(response[:LastRuleModification]), + sampling_target_documents: response[:SamplingTargetDocuments].map { |item| SamplingTargetDocument.from_response(item) } + ) + end + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/matcher.rb b/sampling/xray/lib/opentelemetry/sampling/xray/matcher.rb new file mode 100644 index 000000000..ad29d95f3 --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/matcher.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampling + module XRay + class Matcher + SINGLE_CHAR_WILD_CARD = '?' + ZERO_OR_MORE_CHAR_WILD_CARD = '*' + + # @param [String] input + # @return [Boolean] + def match?(input) + raise(NotImplementedError, 'Subclasses must implement match?') + end + + # @param [String] glob_pattern + # @return [Matcher] + def self.to_matcher(glob_pattern) + if glob_pattern == ZERO_OR_MORE_CHAR_WILD_CARD + TrueMatcher.new + elsif glob_pattern.include?(SINGLE_CHAR_WILD_CARD) || glob_pattern.include?(ZERO_OR_MORE_CHAR_WILD_CARD) + PatternMatcher.new(glob_pattern) + else + StringMatcher.new(glob_pattern) + end + end + end + + class TrueMatcher < Matcher + # @param [String] input + # @return [Boolean] + def match?(input) + true + end + end + + class StringMatcher < Matcher + # @param [String] target + def initialize(target) + @target = target + end + + # @param [String] input + # @return [Boolean] + def match?(input) + input == @target + end + end + + class PatternMatcher < Matcher + # @param [String] glob_pattern + def initialize(glob_pattern) + @pattern = Regexp.quote(glob_pattern).gsub("\\#{ZERO_OR_MORE_CHAR_WILD_CARD}", '.*').gsub("\\#{SINGLE_CHAR_WILD_CARD}", '.') + end + + # @param [String] input + # @return [Boolean] + def match?(input) + !input.nil? && input.match?(@pattern) + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/poller.rb b/sampling/xray/lib/opentelemetry/sampling/xray/poller.rb new file mode 100644 index 000000000..681c874dc --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/poller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampling + module XRay + class Poller + # @param [Client] client + # @param [Cache] cache + # @param [Integer] rule_interval + # @param [Integer] target_interval + def initialize(client:, cache:, rule_interval:, target_interval:) + @cache = cache + @client = client + @rule_interval = rule_interval + @running = false + @target_interval = target_interval + end + + def start + return if @running + + @running = true + start_worker + OpenTelemetry.logger.debug('Started polling') + end + + def stop + @running = false + OpenTelemetry.logger.debug('Stopped polling') + end + + private + + def start_worker + refresh_rules + + Thread.new do + while @running + sleep(@target_interval) + + refresh_targets + refresh_rules if (Time.now - @last_rule_refresh).to_i >= @rule_interval + end + end + end + + def refresh_rules + OpenTelemetry.logger.debug('Refreshing sampling rules') + @cache.update_rules(@client.fetch_sampling_rules.map(&:sampling_rule)) + @last_rule_refresh = Time.now + end + + def refresh_targets + matched_rules = @cache.get_matched_rules + if matched_rules.empty? + OpenTelemetry.logger.debug('Not refreshing sampling targets because no rules matched') + return + end + + OpenTelemetry.logger.debug('Refreshing sampling targets') + response = @client.fetch_sampling_targets(matched_rules) + return if response.nil? + + @cache.update_targets(response.sampling_target_documents) + return unless response.last_rule_modification > @last_rule_refresh + + refresh_rules + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/reservoir.rb b/sampling/xray/lib/opentelemetry/sampling/xray/reservoir.rb new file mode 100644 index 000000000..1e8a76be7 --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/reservoir.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampling + module XRay + class Reservoir + BORROW = :borrow + TAKE = :take + + # @param [Integer] size + def initialize(size) + @current_tick = nil + @quota = nil + @quota_ttl = nil + @size = size + end + + # @return [Symbol, Boolean] + def borrow_or_take? + tick = Time.now.to_i + advance_tick(tick) + + if quota_applicable?(tick) + return false unless can_take? + + @taken += 1 + return TAKE + end + + return false unless can_borrow? + + @borrowed += 1 + BORROW + end + + # @param [Integer] quota + # @param [Integer] quota_ttl + def update_target(quota:, quota_ttl:) + @quota = quota + @quota_ttl = quota_ttl + end + + private + + # @param [Integer] tick + def advance_tick(tick) + return if @current_tick == tick + + @borrowed = 0 + @current_tick = tick + @taken = 0 + end + + # @return [Boolean] + def can_borrow? + @size.positive? && @borrowed < 1 + end + + # @param [Integer] tick + # @return [Boolean] + def quota_applicable?(tick) + @quota && @quota >= 0 && @quota_ttl && @quota_ttl >= tick + end + + # @return [Boolean] + def can_take? + @taken < @quota + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/sampler.rb b/sampling/xray/lib/opentelemetry/sampling/xray/sampler.rb new file mode 100644 index 000000000..cef9d8c87 --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/sampler.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative('cache') +require_relative('client') +require_relative('poller') + +module OpenTelemetry + module Sampling + module XRay + class Sampler + DEFAULT_RULE_POLLING_INTERVAL = 5 * 60 + DEFAULT_TARGET_POLLING_INTERVAL = 10 + + # @param [String] endpoint + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @param [OpenTelemetry::SDK::Trace::Samplers] fallback_sampler + def initialize(endpoint:, resource:, fallback_sampler:) + raise(ArgumentError, 'resource must not be nil') if resource.nil? + raise(ArgumentError, 'fallback_sampler must not be nil') if fallback_sampler.nil? + + @resource = resource + @fallback_sampler = fallback_sampler + @cache = Cache.new + @poller = Poller.new( + client: Client.new(endpoint: endpoint), + cache: @cache, + rule_interval: DEFAULT_RULE_POLLING_INTERVAL, + target_interval: DEFAULT_TARGET_POLLING_INTERVAL + ) + end + + def start + @poller.start + end + + # @param [String] trace_id + # @param [OpenTelemetry::Context] parent_context + # @param [Enumerable] links + # @param [String] name + # @param [Symbol] kind + # @param [Hash] attributes + # @return [OpenTelemetry::SDK::Trace::Samplers::Result] + def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:) + matching_rule = @cache.get_first_matching_rule( + attributes: attributes, + resource: @resource + ) + + if matching_rule.nil? + @fallback_sampler.should_sample?( + trace_id: trace_id, + parent_context: parent_context, + links: links, + name: name, + kind: kind, + attributes: attributes + ) + elsif matching_rule.can_sample? + OpenTelemetry::SDK::Trace::Samplers::Result.new( + decision: OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + tracestate: OpenTelemetry::Trace.current_span(parent_context).context.tracestate + ) + else + OpenTelemetry::SDK::Trace::Samplers::Result.new( + decision: OpenTelemetry::SDK::Trace::Samplers::Decision::DROP, + tracestate: OpenTelemetry::Trace.current_span(parent_context).context.tracestate + ) + end + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/sampling_rule.rb b/sampling/xray/lib/opentelemetry/sampling/xray/sampling_rule.rb new file mode 100644 index 000000000..e2759ee5e --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/sampling_rule.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require_relative('matcher') +require_relative('reservoir') +require_relative('statistic') + +module OpenTelemetry + module Sampling + module XRay + class SamplingRule + AWS_LAMBDA = 'aws_lambda' + CLOUD_RESOURCE_ID = 'cloud.resource_id' + XRAY_CLOUD_PLATFORM = { + 'aws_ec2' => 'AWS::EC2::Instance', + 'aws_ecs' => 'AWS::ECS::Container', + 'aws_eks' => 'AWS::EKS::Container', + 'aws_elastic_beanstalk' => 'AWS::ElasticBeanstalk::Environment', + 'aws_lambda' => 'AWS::Lambda::Function' + }.freeze + + attr_reader( + :priority, + :reservoir, + :rule_name, + :statistic + ) + + # @param [Hash] attributes + # @param [Float] fixed_rate + # @param [String] host + # @param [String] http_method + # @param [Integer] priority + # @param [Integer] reservoir_size + # @param [String] resource_arn + # @param [String] rule_arn + # @param [String] rule_name + # @param [String] service_name + # @param [String] service_type + # @param [String] url_path + # @param [Integer] version + def initialize( + attributes:, + fixed_rate:, + host:, + http_method:, + priority:, + reservoir_size:, + resource_arn:, + rule_arn:, + rule_name:, + service_name:, + service_type:, + url_path:, + version: + ) + @fixed_rate = fixed_rate + @rule_name = rule_name + @priority = priority + + @attribute_matchers = attributes.transform_values { |value| Matcher.to_matcher(value) } + @host_matcher = Matcher.to_matcher(host) + @http_method_matcher = Matcher.to_matcher(http_method) + @resource_arn_matcher = Matcher.to_matcher(resource_arn) + @service_name_matcher = Matcher.to_matcher(service_name) + @service_type_matcher = Matcher.to_matcher(service_type) + @url_path_matcher = Matcher.to_matcher(url_path) + + @reservoir = Reservoir.new(reservoir_size) + @statistic = Statistic.new + + @lock = Mutex.new + end + + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @param [Hash] attributes + # @return [Boolean] + def match?(resource:, attributes:) + host = attributes&.dig(OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME) || attributes&.dig(OpenTelemetry::SemanticConventions::Trace::HTTP_HOST) + http_method = attributes&.dig(OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD) + http_target = attributes&.dig(OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET) + http_url = attributes&.dig(OpenTelemetry::SemanticConventions::Trace::HTTP_URL) + + @attribute_matchers.all? { |key, matcher| matcher.match?(attributes&.dig(key)) } && + @host_matcher.match?(host) && + @http_method_matcher.match?(http_method) && + @resource_arn_matcher.match?(get_arn(attributes, resource)) && + @service_name_matcher.match?(get_attribute(resource, OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME)) && + @service_type_matcher.match?(get_service_type(resource)) && + @url_path_matcher.match?(extract_http_target(http_target, http_url)) + end + + # @return [Boolean] + def can_sample? + @lock.synchronize do + @statistic.increment_request_count + case @reservoir.borrow_or_take? + when Reservoir::BORROW + @statistic.increment_borrow_count + true + when Reservoir::TAKE + @statistic.increment_sampled_count + true + else + if rand <= @fixed_rate + @statistic.increment_sampled_count + true + else + false + end + end + end + end + + # @return [Boolean] + def ever_matched? + @statistic.request_count.positive? + end + + # @param [SamplingRule] rule + def merge(rule) + return if rule.nil? || rule.rule_name != @rule_name + + @statistic = rule.statistic + @reservoir = rule.reservoir + end + + # @param [Client::SamplingTargetDocument] target + def with_target(target) + return if target.nil? || target.rule_name != @rule_name + + @fixed_rate = target.fixed_rate + @reservoir.update_target( + quota: target.reservoir_quota, + quota_ttl: target.reservoir_quota_ttl + ) + end + + # @return [Statistic] + def snapshot_statistic + @lock.synchronize do + statistic = @statistic + @statistic = Statistic.new + statistic + end + end + + private + + # @param [String] http_target + # @param [String] http_url + # @return [String] + def extract_http_target(http_target, http_url) + return http_target if !http_target.nil? || http_url.nil? + + scheme_end_index = http_url.index('://') + # Per spec, http.url is always populated with scheme://host[:port]/path?query[#fragment] + return http_target if scheme_end_index.negative? + + path_index = http_url.index('/', scheme_end_index + '://'.length) + if path_index.negative? + # No path, equivalent to root path. + '/' + else + http_url[path_index..-1] + end + end + + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @return [String] + def get_service_type(resource) + cloud_platform = get_attribute(resource, OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM) + XRAY_CLOUD_PLATFORM[cloud_platform] + end + + # @param [Hash] attributes + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @return [String] + def get_arn(attributes, resource) + arn = get_attribute(resource, OpenTelemetry::SemanticConventions::Resource::AWS_ECS_CONTAINER_ARN) + return arn unless arn.nil? + + cloud_platform = get_attribute(resource, OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM) + return unless cloud_platform == AWS_LAMBDA + + get_lambda_arn(attributes, resource) + end + + # @param [Hash] attributes + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @return [String] + def get_lambda_arn(attributes, resource) + get_attribute(resource, CLOUD_RESOURCE_ID) || attributes[CLOUD_RESOURCE_ID] + end + + # @param [OpenTelemetry::SDK::Resources::Resource] resource + # @param [String] attribute + # @return [Object] + def get_attribute(resource, attribute) + resource.attribute_enumerator.find { |key, _| key == attribute }&.last + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/statistic.rb b/sampling/xray/lib/opentelemetry/sampling/xray/statistic.rb new file mode 100644 index 000000000..9513f45cc --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/statistic.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampling + module XRay + class Statistic + attr_reader( + :borrow_count, + :request_count, + :sampled_count + ) + + def initialize + @borrow_count = 0 + @request_count = 0 + @sampled_count = 0 + end + + def increment_borrow_count + @borrow_count += 1 + end + + def increment_request_count + @request_count += 1 + end + + def increment_sampled_count + @sampled_count += 1 + end + end + end + end +end diff --git a/sampling/xray/lib/opentelemetry/sampling/xray/version.rb b/sampling/xray/lib/opentelemetry/sampling/xray/version.rb new file mode 100644 index 000000000..ebd091e37 --- /dev/null +++ b/sampling/xray/lib/opentelemetry/sampling/xray/version.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Copyright OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module Sampling + module XRay + VERSION = '0.0.1' + end + end +end diff --git a/sampling/xray/opentelemetry-sampling-xray.gemspec b/sampling/xray/opentelemetry-sampling-xray.gemspec new file mode 100644 index 000000000..76ab8579e --- /dev/null +++ b/sampling/xray/opentelemetry-sampling-xray.gemspec @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Copyright 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/sampling/xray/version') + +Gem::Specification.new do |spec| + spec.name = 'opentelemetry-sampling-xray' + spec.version = OpenTelemetry::Sampling::XRay::VERSION + spec.authors = ['OpenTelemetry Authors'] + spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] + + spec.summary = 'XRay Remote Sampling Extension for the OpenTelemetry framework' + spec.description = 'XRay Remote Sampling Extension 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') + %w[LICENSE .yardopts] + spec.require_paths = %w[lib] + spec.required_ruby_version = '>= 2.6.0' + + spec.add_dependency('opentelemetry-api', '~> 1.0') + + spec.add_development_dependency('bundler', '~> 2.4') + spec.add_development_dependency('minitest', '~> 5.0') + spec.add_development_dependency('opentelemetry-sdk', '~> 1.1') + spec.add_development_dependency('rake', '~> 13.0') + spec.add_development_dependency('rubocop', '~> 1.57.1') + spec.add_development_dependency('webmock', '~> 3.19.1') + spec.add_development_dependency('yard', '~> 0.9') + + 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/sampling/xray' + spec.metadata['bug_tracker_uri'] = 'https://github.com/open-telemetry/opentelemetry-ruby-contrib/issues' + spec.metadata['documentation_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}" + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/cache_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/cache_test.rb new file mode 100644 index 000000000..28762dd6c --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/cache_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/cache') + +describe(OpenTelemetry::Sampling::XRay::Cache) do + describe('#get_first_matching_rule') do + it('returns nil if no rule matches') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + resource = OpenTelemetry::SDK::Resources::Resource.create + + rule = Minitest::Mock.new + rule.expect(:match?, false, resource: resource, attributes: {}) + + cache.instance_variable_set(:@rules, [rule]) + + _( + cache.get_first_matching_rule( + attributes: {}, + resource: resource + ) + ).must_be_nil + + rule.verify + end + + it('returns the first matching rule') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + resource = OpenTelemetry::SDK::Resources::Resource.create + + rule = Minitest::Mock.new + rule.expect(:match?, true, resource: resource, attributes: {}) + rule.expect(:==, true, [rule]) + + cache.instance_variable_set(:@rules, [rule]) + + _( + cache.get_first_matching_rule( + attributes: {}, + resource: resource + ) + ).must_equal(rule) + + rule.verify + end + end + + describe('#get_matched_rules') do + it('returns rules that matched at least once') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + + matched_rule = Minitest::Mock.new + matched_rule.expect(:ever_matched?, true) + matched_rule.expect(:==, true, [matched_rule]) + + not_matched_rule = Minitest::Mock.new + not_matched_rule.expect(:ever_matched?, false) + + cache.instance_variable_set(:@rules, [matched_rule, not_matched_rule].shuffle) + + matched_rules = cache.get_matched_rules + _(matched_rules.length).must_equal(1) + _(matched_rules.first).must_equal(matched_rule) + + matched_rule.verify + not_matched_rule.verify + end + end + + describe('#update_rules') do + it('sorts rules by priority and name') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + rules = [ + build_rule( + rule_name: 'b', + priority: 1, + fixed_rate: 0.5 + ), + build_rule( + rule_name: 'a', + priority: 2, + fixed_rate: 0.5 + ), + build_rule( + rule_name: 'a', + priority: 1, + fixed_rate: 0.5 + ) + ] + + cache.update_rules(rules) + + _(cache.instance_variable_get('@rules')).must_equal( + [ + rules[2], + rules[0], + rules[1] + ] + ) + end + + it('merges rules with their predecessor') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + + rule = build_rule + reservoir = OpenTelemetry::Sampling::XRay::Reservoir.new(rand(0..100)) + rule.instance_variable_set(:@reservoir, reservoir) + statistic = OpenTelemetry::Sampling::XRay::Statistic.new + rule.instance_variable_set(:@statistic, statistic) + + cache.update_rules([rule]) + + new_rule = build_rule(rule_name: rule.rule_name) + cache.update_rules([new_rule]) + + _(cache.instance_variable_get(:@rules).length).must_equal(1) + _(cache.instance_variable_get(:@rules).first).must_equal(new_rule) + _(new_rule.reservoir).must_equal(reservoir) + _(new_rule.statistic).must_equal(statistic) + end + end + + describe('#update_targets') do + it('updates rule targets') do + cache = OpenTelemetry::Sampling::XRay::Cache.new + target = build_target_document + + rule = Minitest::Mock.new + rule.expect(:with_target, nil, [target]) + rule.expect(:rule_name, target.rule_name) + + cache.instance_variable_set(:@rules, [rule]) + cache.update_targets([target]) + + rule.verify + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/client_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/client_test.rb new file mode 100644 index 000000000..fa9c47efe --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/client_test.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') + +describe(OpenTelemetry::Sampling::XRay::Client) do + before { WebMock.disable_net_connect! } + after { WebMock.allow_net_connect! } + + describe('#fetch_sampling_rules') do + describe('should return an empty array if there are no sampling rules') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + + before do + stub_request(:post, "#{endpoint}/GetSamplingRules") + .to_return( + body: { SamplingRuleRecords: [] }.to_json, + headers: { content_type: 'application/json' } + ) + end + it do + rules = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_rules + + _(rules).must_equal([]) + end + end + + describe('should return an empty array if the request results in an error') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + + before do + stub_request(:post, "#{endpoint}/GetSamplingRules") + .to_return(status: 500) + end + it do + rules = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_rules + + _(rules).must_equal([]) + end + end + + describe('should return the sampling rules') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + let(:record) do + { + Attributes: { SecureRandom.uuid.to_s.to_sym => SecureRandom.uuid.to_s }, + FixedRate: rand, + Host: SecureRandom.uuid.to_s, + HTTPMethod: SecureRandom.uuid.to_s, + Priority: rand, + ReservoirSize: rand, + ResourceARN: SecureRandom.uuid.to_s, + RuleARN: SecureRandom.uuid.to_s, + RuleName: SecureRandom.uuid.to_s, + ServiceName: SecureRandom.uuid.to_s, + ServiceType: SecureRandom.uuid.to_s, + URLPath: SecureRandom.uuid.to_s, + Version: rand + } + end + + before do + stub_request(:post, "#{endpoint}/GetSamplingRules") + .to_return( + body: { + SamplingRuleRecords: [ + { + SamplingRule: record, + CreatedAt: Time.now.to_i, + ModifiedAt: Time.now.to_i + } + ] + }.to_json, + headers: { content_type: 'application/json' } + ) + end + it do + rules = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_rules + + _(rules.size).must_equal(1) + + rule = rules.first.sampling_rule + _(rule.priority).must_equal(record[:Priority]) + _(rule.rule_name).must_equal(record[:RuleName]) + end + end + end + + describe('#fetch_sampling_targets') do + describe('should return an empty array if there are no sampling targets') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + + before do + stub_request(:post, "#{endpoint}/SamplingTargets") + .to_return( + body: { + SamplingTargetDocuments: [], + LastRuleModification: Time.now.to_i + }.to_json, + headers: { content_type: 'application/json' } + ) + end + it do + response = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_targets([]) + + _(response.sampling_target_documents).must_equal([]) + end + end + + describe('should return an empty array if the request results in an error') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + + before do + stub_request(:post, "#{endpoint}/SamplingTargets") + .to_return(status: 500) + end + it do + response = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_targets([]) + + _(response).must_be_nil + end + end + + describe('should return the sampling rules') do + let(:endpoint) { "http://#{SecureRandom.uuid}" } + let(:document) do + { + FixedRate: rand, + Interval: rand, + ReservoirQuota: rand, + ReservoirQuotaTTL: rand, + RuleName: SecureRandom.uuid.to_s + } + end + + before do + stub_request(:post, "#{endpoint}/SamplingTargets") + .to_return( + body: { + SamplingTargetDocuments: [document], + LastRuleModification: Time.now.to_i + }.to_json, + headers: { content_type: 'application/json' } + ) + end + it do + response = OpenTelemetry::Sampling::XRay::Client + .new(endpoint: endpoint) + .fetch_sampling_targets([]) + + targets = response.sampling_target_documents + _(targets.size).must_equal(1) + + target = targets.first + _(target.fixed_rate).must_equal(document[:FixedRate]) + _(target.interval).must_equal(document[:Interval]) + _(target.reservoir_quota).must_equal(document[:ReservoirQuota]) + _(target.reservoir_quota_ttl).must_equal(document[:ReservoirQuotaTTL]) + _(target.rule_name).must_equal(document[:RuleName]) + end + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/matcher_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/matcher_test.rb new file mode 100644 index 000000000..50f4db932 --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/matcher_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/matcher') + +describe(OpenTelemetry::Sampling::XRay::Matcher) do + describe('to_matcher') do + it('returns a TrueMatcher for *') do + _( + OpenTelemetry::Sampling::XRay::Matcher.to_matcher('*') + ).must_be_instance_of( + OpenTelemetry::Sampling::XRay::TrueMatcher + ) + end + + it('returns a StringMatcher for a string') do + _( + OpenTelemetry::Sampling::XRay::Matcher.to_matcher(SecureRandom.uuid.to_s) + ).must_be_instance_of( + OpenTelemetry::Sampling::XRay::StringMatcher + ) + end + + it('returns a PatternMatcher for a glob pattern') do + _( + OpenTelemetry::Sampling::XRay::Matcher.to_matcher('a*b?c') + ).must_be_instance_of( + OpenTelemetry::Sampling::XRay::PatternMatcher + ) + end + end +end + +describe(OpenTelemetry::Sampling::XRay::TrueMatcher) do + describe('match?') do + it('returns true') do + _( + OpenTelemetry::Sampling::XRay::TrueMatcher.new.match?(SecureRandom.uuid.to_s) + ).must_equal(true) + end + end +end + +describe(OpenTelemetry::Sampling::XRay::StringMatcher) do + describe('match?') do + it('returns true for the same string') do + string = SecureRandom.uuid.to_s + _( + OpenTelemetry::Sampling::XRay::StringMatcher.new(string).match?(string) + ).must_equal(true) + end + + it('returns false for a different string') do + _( + OpenTelemetry::Sampling::XRay::StringMatcher.new(SecureRandom.uuid.to_s).match?(SecureRandom.uuid.to_s) + ).must_equal(false) + end + end +end + +describe(OpenTelemetry::Sampling::XRay::PatternMatcher) do + describe('match?') do + it('returns true for a matching string') do + _( + OpenTelemetry::Sampling::XRay::PatternMatcher.new('a*b?c').match?('abzc') + ).must_equal(true) + _( + OpenTelemetry::Sampling::XRay::PatternMatcher.new('a*b?c').match?('axybzc') + ).must_equal(true) + end + + it('returns false for a non-matching string') do + _( + OpenTelemetry::Sampling::XRay::PatternMatcher.new('z*b?c').match?(SecureRandom.uuid.to_s) + ).must_equal(false) + end + + it('returns false for nil') do + _( + OpenTelemetry::Sampling::XRay::PatternMatcher.new('a*b?c').match?(nil) + ).must_equal(false) + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/poller_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/poller_test.rb new file mode 100644 index 000000000..59f49125f --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/poller_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/poller') + +describe(OpenTelemetry::Sampling::XRay::Poller) do + describe('#start') do + it('updates rules instantly') do + cache = Minitest::Mock.new + client = Minitest::Mock.new + rules = [ + OpenTelemetry::Sampling::XRay::Client::SamplingRuleRecord.new( + sampling_rule: build_rule, + created_at: Time.now, + modified_at: Time.now + ) + ] + + client.expect(:fetch_sampling_rules, rules) + cache.expect(:update_rules, nil, [rules.map(&:sampling_rule)]) + + poller = OpenTelemetry::Sampling::XRay::Poller.new( + client: client, + cache: cache, + rule_interval: 1, + target_interval: 0 + ) + + poller.start + poller.stop + + client.verify + cache.verify + end + + it('updates rules periodically') do + cache = Minitest::Mock.new + client = Minitest::Mock.new + + first_rules = [ + OpenTelemetry::Sampling::XRay::Client::SamplingRuleRecord.new( + sampling_rule: build_rule, + created_at: Time.now, + modified_at: Time.now + ) + ] + second_rules = [ + OpenTelemetry::Sampling::XRay::Client::SamplingRuleRecord.new( + sampling_rule: build_rule, + created_at: Time.now, + modified_at: Time.now + ) + ] + + client.expect(:fetch_sampling_rules, first_rules) + cache.expect(:update_rules, nil, [first_rules.map(&:sampling_rule)]) + cache.expect(:get_matched_rules, []) + client.expect(:fetch_sampling_rules, second_rules) + cache.expect(:update_rules, nil, [second_rules.map(&:sampling_rule)]) + + poller = OpenTelemetry::Sampling::XRay::Poller.new( + client: client, + cache: cache, + rule_interval: 0.5, + target_interval: 1 + ) + + poller.start + sleep(1.5) + poller.stop + + client.verify + cache.verify + end + + it('updates targets periodically') do + cache = Minitest::Mock.new + client = Minitest::Mock.new + rules = [ + OpenTelemetry::Sampling::XRay::Client::SamplingRuleRecord.new( + sampling_rule: build_rule, + created_at: Time.now, + modified_at: Time.now + ) + ] + matched_rules = [build_rule] + targets = OpenTelemetry::Sampling::XRay::Client::SamplingTargetResponse.new( + last_rule_modification: Time.now, + sampling_target_documents: [ + OpenTelemetry::Sampling::XRay::Client::SamplingTargetDocument.new( + rule_name: SecureRandom.uuid.to_s, + fixed_rate: rand, + reservoir_quota: rand(0..100), + reservoir_quota_ttl: rand(0..100), + interval: rand(0..100) + ) + ] + ) + + client.expect(:fetch_sampling_rules, rules) + cache.expect(:update_rules, nil, [rules.map(&:sampling_rule)]) + cache.expect(:get_matched_rules, matched_rules) + client.expect(:fetch_sampling_targets, targets, [matched_rules]) + cache.expect(:update_targets, nil, [targets.sampling_target_documents]) + + poller = OpenTelemetry::Sampling::XRay::Poller.new( + client: client, + cache: cache, + rule_interval: 10, + target_interval: 1 + ) + + poller.start + sleep(1.5) + poller.stop + + client.verify + cache.verify + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/reservoir_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/reservoir_test.rb new file mode 100644 index 000000000..ce2709469 --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/reservoir_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/reservoir') + +describe(OpenTelemetry::Sampling::XRay::Reservoir) do + describe('#borrow_or_take?') do + it('should take if the quota is applicable and not yet fully consumed') do + reservoir = OpenTelemetry::Sampling::XRay::Reservoir.new(0) + reservoir.update_target(quota: 10, quota_ttl: Time.now.to_i + 100) + + Time.stub(:now, Time.now) do + 10.times.each do |i| + _(reservoir.borrow_or_take?).must_equal(OpenTelemetry::Sampling::XRay::Reservoir::TAKE) + _(reservoir.instance_variable_get(:@taken)).must_equal(i + 1) + end + + _(reservoir.borrow_or_take?).must_equal(false) + _(reservoir.instance_variable_get(:@taken)).must_equal(10) + end + end + + it('should borrow if it cannot take and has not borrowed yet') do + reservoir = OpenTelemetry::Sampling::XRay::Reservoir.new(rand(1..100)) + + Time.stub(:now, Time.now) do + _(reservoir.borrow_or_take?).must_equal(OpenTelemetry::Sampling::XRay::Reservoir::BORROW) + _(reservoir.instance_variable_get(:@borrowed)).must_equal(1) + + _(reservoir.borrow_or_take?).must_equal(false) + _(reservoir.instance_variable_get(:@borrowed)).must_equal(1) + end + end + + it('should neither borrow nor take') do + reservoir = OpenTelemetry::Sampling::XRay::Reservoir.new(0) + + Time.stub(:now, Time.now) do + _(reservoir.borrow_or_take?).must_equal(false) + _(reservoir.instance_variable_get(:@borrowed)).must_equal(0) + _(reservoir.instance_variable_get(:@taken)).must_equal(0) + end + end + + it('should clear its state when the time advances') do + reservoir = OpenTelemetry::Sampling::XRay::Reservoir.new(0) + reservoir.update_target(quota: 1, quota_ttl: Time.now.to_i + 100) + + now = Time.now + + Time.stub(:now, now) do + _(reservoir.borrow_or_take?).must_equal(OpenTelemetry::Sampling::XRay::Reservoir::TAKE) + _(reservoir.borrow_or_take?).must_equal(false) + end + + Time.stub(:now, now + 1) do + _(reservoir.borrow_or_take?).must_equal(OpenTelemetry::Sampling::XRay::Reservoir::TAKE) + _(reservoir.borrow_or_take?).must_equal(false) + end + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/sampler_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/sampler_test.rb new file mode 100644 index 000000000..f60c001fb --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/sampler_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/cache') + +describe(OpenTelemetry::Sampling::XRay::Sampler) do + describe('#initialize') do + it('should initialize') do + _( + OpenTelemetry::Sampling::XRay::Sampler.new( + endpoint: SecureRandom.uuid.to_s, + resource: OpenTelemetry::SDK::Resources::Resource.create({}), + fallback_sampler: OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(rand) + ) + ).wont_be_nil + end + + it('should raise ArgumentError when resource is nil') do + _( + lambda { + OpenTelemetry::Sampling::XRay::Sampler.new( + endpoint: SecureRandom.uuid.to_s, + resource: nil, + fallback_sampler: OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(rand) + ) + } + ).must_raise(ArgumentError) + end + + it('should raise ArgumentError when fallback_sampler is nil') do + _( + lambda { + OpenTelemetry::Sampling::XRay::Sampler.new( + endpoint: SecureRandom.uuid.to_s, + resource: OpenTelemetry::SDK::Resources::Resource.create({}), + fallback_sampler: nil + ) + } + ).must_raise(ArgumentError) + end + end + + describe('#should_sample?') do + [true, false].each do |should_sample| + it("should call the matching rule and return #{should_sample ? 'sampled' : 'not sampled'}") do + fallback_sampler = Minitest::Mock.new + fallback_sampler.expect(:nil?, false) + + cache = Minitest::Mock.new + rule = Minitest::Mock.new + resource = OpenTelemetry::SDK::Resources::Resource.create({}) + sampler = OpenTelemetry::Sampling::XRay::Cache.stub(:new, cache) do + OpenTelemetry::Sampling::XRay::Sampler.new( + endpoint: SecureRandom.uuid.to_s, + resource: resource, + fallback_sampler: fallback_sampler + ) + end + cache.expect(:get_first_matching_rule, rule, [], attributes: {}, resource: resource) + rule.expect(:nil?, false) + rule.expect( + :can_sample?, + should_sample + ) + + _( + sampler.should_sample?( + trace_id: SecureRandom.uuid.to_s, + parent_context: nil, + links: [], + name: SecureRandom.uuid.to_s, + kind: :internal, + attributes: {} + ).sampled? + ).must_equal(should_sample) + + fallback_sampler.verify + cache.verify + rule.verify + end + end + + it('should call the fallback sampler if there is no matching rule') do + trace_id = SecureRandom.uuid.to_s + name = SecureRandom.uuid.to_s + fallback_sampler = Minitest::Mock.new + result = OpenTelemetry::SDK::Trace::Samplers::Result.new( + decision: OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE, + tracestate: OpenTelemetry::Trace::Tracestate.from_hash({}) + ) + fallback_sampler.expect(:nil?, false) + fallback_sampler.expect( + :should_sample?, + result, + [], + trace_id: trace_id, + parent_context: nil, + links: [], + name: name, + kind: :internal, + attributes: {} + ) + + cache = Minitest::Mock.new + resource = OpenTelemetry::SDK::Resources::Resource.create({}) + sampler = OpenTelemetry::Sampling::XRay::Cache.stub(:new, cache) do + OpenTelemetry::Sampling::XRay::Sampler.new( + endpoint: SecureRandom.uuid.to_s, + resource: resource, + fallback_sampler: fallback_sampler + ) + end + cache.expect(:get_first_matching_rule, nil, [], attributes: {}, resource: resource) + + _( + sampler.should_sample?( + trace_id: trace_id, + parent_context: nil, + links: [], + name: name, + kind: :internal, + attributes: {} + ) + ).must_equal(result) + + fallback_sampler.verify + cache.verify + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/sampling_rule_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/sampling_rule_test.rb new file mode 100644 index 000000000..38f2fd93a --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/sampling_rule_test.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') + +describe(OpenTelemetry::Sampling::XRay::SamplingRule) do + describe('#match?') do + it('returns true when all properties are wildcards') do + rule = build_rule + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns true when all properties except the host are wildcards and it matches') do + host = SecureRandom.uuid.to_s + rule = build_rule(host: host) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => host + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_HOST => host + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns false when all properties except the host are wildcards and it does not match') do + rule = build_rule(host: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => SecureRandom.uuid.to_s + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_HOST => SecureRandom.uuid.to_s + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + end + + it('returns true when all properties except the http_method are wildcards and it matches') do + http_method = SecureRandom.uuid.to_s + rule = build_rule(http_method: http_method) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => http_method + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns false when all properties except the http_method are wildcards and it does not match') do + rule = build_rule(http_method: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => SecureRandom.uuid.to_s + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + end + + it('returns true when all properties except the resource_arn are wildcards and it matches') do + resource_arn = SecureRandom.uuid.to_s + rule = build_rule(resource_arn: resource_arn) + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::AWS_ECS_CONTAINER_ARN => resource_arn + ) + ) + ).must_equal(true) + end + + it('returns false when all properties except the resource_arn are wildcards and it does not match') do + rule = build_rule(resource_arn: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::AWS_ECS_CONTAINER_ARN => SecureRandom.uuid.to_s + ) + ) + ).must_equal(false) + end + + it('returns true when all properties except the service_name are wildcards and it matches') do + service_name = SecureRandom.uuid.to_s + rule = build_rule(service_name: service_name) + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => service_name + ) + ) + ).must_equal(true) + end + + it('returns false when all properties except the service_name are wildcards and it does not match') do + rule = build_rule(service_name: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => SecureRandom.uuid.to_s + ) + ) + ).must_equal(false) + end + + it('returns true when all properties except the service_type are wildcards and it matches') do + rule = build_rule(service_type: 'AWS::EC2::Instance') + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM => 'aws_ec2' + ) + ) + ).must_equal(true) + end + + it('returns false when all properties except the service_type are wildcards and it does not match') do + rule = build_rule(service_type: 'AWS::EC2::Instance') + + _( + rule.match?( + attributes: {}, + resource: OpenTelemetry::SDK::Resources::Resource.create( + OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM => 'aws_ecs' + ) + ) + ).must_equal(false) + end + + it('returns true when all properties except the url_path are wildcards and the http_target matches') do + url_path = SecureRandom.uuid.to_s + rule = build_rule(url_path: url_path) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => url_path + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns false when all properties except the url_path are wildcards and the http_target does not match') do + rule = build_rule(url_path: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => SecureRandom.uuid.to_s + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + end + + it('returns true when all properties except the url_path are wildcards and the http_url matches') do + url_path = "/#{SecureRandom.uuid}" + rule = build_rule(url_path: url_path) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_URL => "http://#{SecureRandom.uuid}#{url_path}" + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns false when all properties except the url_path are wildcards and the http_url does not match') do + rule = build_rule(url_path: SecureRandom.uuid.to_s) + + _( + rule.match?( + attributes: { + OpenTelemetry::SemanticConventions::Trace::HTTP_URL => "http://#{SecureRandom.uuid}/#{SecureRandom.uuid}" + }, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + end + + it('returns true when all properties except the attributes are wildcards and all attributes match') do + attributes = { + SecureRandom.uuid.to_s => SecureRandom.uuid.to_s, + SecureRandom.uuid.to_s => SecureRandom.uuid.to_s, + SecureRandom.uuid.to_s => SecureRandom.uuid.to_s + } + rule = build_rule(attributes: attributes) + + _( + rule.match?( + attributes: attributes, + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(true) + end + + it('returns false when all properties except the attributes are wildcards and one attribute does not match') do + key = SecureRandom.uuid.to_s + attributes = { + SecureRandom.uuid.to_s => SecureRandom.uuid.to_s, + SecureRandom.uuid.to_s => SecureRandom.uuid.to_s, + key => SecureRandom.uuid.to_s + } + rule = build_rule(attributes: attributes) + + _( + rule.match?( + attributes: attributes.merge(key => SecureRandom.uuid.to_s), + resource: OpenTelemetry::SDK::Resources::Resource.create + ) + ).must_equal(false) + end + end + + describe('#can_sample?') do + it('increments the request count and returns true if it can borrow from the reservoir') do + reservoir = Minitest::Mock.new + statistic = Minitest::Mock.new + rule = OpenTelemetry::Sampling::XRay::Reservoir.stub(:new, reservoir) do + OpenTelemetry::Sampling::XRay::Statistic.stub(:new, statistic) { build_rule } + end + + reservoir.expect(:borrow_or_take?, OpenTelemetry::Sampling::XRay::Reservoir::BORROW) + statistic.expect(:increment_borrow_count, nil) + statistic.expect(:increment_request_count, nil) + + _(rule.can_sample?).must_equal(true) + + reservoir.verify + statistic.verify + end + + it('increments the request count and returns true if it can take from the reservoir') do + reservoir = Minitest::Mock.new + statistic = Minitest::Mock.new + rule = OpenTelemetry::Sampling::XRay::Reservoir.stub(:new, reservoir) do + OpenTelemetry::Sampling::XRay::Statistic.stub(:new, statistic) { build_rule } + end + + reservoir.expect(:borrow_or_take?, OpenTelemetry::Sampling::XRay::Reservoir::TAKE) + statistic.expect(:increment_request_count, nil) + statistic.expect(:increment_sampled_count, nil) + + _(rule.can_sample?).must_equal(true) + + reservoir.verify + statistic.verify + end + + it('returns true according to fixed_rate') do + reservoir = Minitest::Mock.new + statistic = Minitest::Mock.new + rule = OpenTelemetry::Sampling::XRay::Reservoir.stub(:new, reservoir) do + OpenTelemetry::Sampling::XRay::Statistic.stub(:new, statistic) { build_rule(fixed_rate: 2) } + end + + reservoir.expect(:borrow_or_take?, nil) + statistic.expect(:increment_request_count, nil) + statistic.expect(:increment_sampled_count, nil) + + _(rule.can_sample?).must_equal(true) + + reservoir.verify + statistic.verify + end + + it('returns false according to fixed_rate') do + reservoir = Minitest::Mock.new + statistic = Minitest::Mock.new + rule = OpenTelemetry::Sampling::XRay::Reservoir.stub(:new, reservoir) do + OpenTelemetry::Sampling::XRay::Statistic.stub(:new, statistic) { build_rule(fixed_rate: 0) } + end + + reservoir.expect(:borrow_or_take?, nil) + statistic.expect(:increment_request_count, nil) + + _(rule.can_sample?).must_equal(false) + + reservoir.verify + statistic.verify + end + end +end diff --git a/sampling/xray/test/opentelemetry/sampling/xray/statistic_test.rb b/sampling/xray/test/opentelemetry/sampling/xray/statistic_test.rb new file mode 100644 index 000000000..2659ac20a --- /dev/null +++ b/sampling/xray/test/opentelemetry/sampling/xray/statistic_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('test_helper') +require('opentelemetry/sampling/xray/statistic') + +describe(OpenTelemetry::Sampling::XRay::Statistic) do + describe('#increment_borrow_count') do + it('should increment the borrowed count') do + statistic = OpenTelemetry::Sampling::XRay::Statistic.new + increments = rand(0..100) + increments.times.each { statistic.increment_borrow_count } + _(statistic.instance_variable_get(:@borrow_count)).must_equal(increments) + end + end + + describe('#increment_request_count') do + it('should increment the request count') do + statistic = OpenTelemetry::Sampling::XRay::Statistic.new + increments = rand(0..100) + increments.times.each { statistic.increment_request_count } + _(statistic.instance_variable_get(:@request_count)).must_equal(increments) + end + end + + describe('#increment_sampled_count') do + it('should increment the sampled count') do + statistic = OpenTelemetry::Sampling::XRay::Statistic.new + increments = rand(0..100) + increments.times.each { statistic.increment_sampled_count } + _(statistic.instance_variable_get(:@sampled_count)).must_equal(increments) + end + end +end diff --git a/sampling/xray/test/test_factory.rb b/sampling/xray/test/test_factory.rb new file mode 100644 index 000000000..54687637d --- /dev/null +++ b/sampling/xray/test/test_factory.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('opentelemetry/sampling/xray/sampling_rule') +require('opentelemetry/sampling/xray/client') + +# @param [Hash] attributes +# @param [Float] fixed_rate +# @param [String] host +# @param [String] http_method +# @param [Integer] priority +# @param [Integer] reservoir_size +# @param [String] resource_arn +# @param [String] rule_arn +# @param [String] rule_name +# @param [String] service_name +# @param [String] service_type +# @param [String] url_path +# @param [Integer] version +# @return [OpenTelemetry::Sampling::XRay::SamplingRule] +def build_rule( + attributes: {}, + fixed_rate: rand, + host: '*', + http_method: '*', + priority: rand(0..100), + reservoir_size: rand(0..100), + resource_arn: '*', + rule_arn: SecureRandom.uuid.to_s, + rule_name: SecureRandom.uuid.to_s, + service_name: '*', + service_type: '*', + url_path: '*', + version: rand(0..100) +) + OpenTelemetry::Sampling::XRay::SamplingRule.new( + attributes: attributes, + fixed_rate: fixed_rate, + host: host, + http_method: http_method, + priority: priority, + reservoir_size: reservoir_size, + resource_arn: resource_arn, + rule_arn: rule_arn, + rule_name: rule_name, + service_name: service_name, + service_type: service_type, + url_path: url_path, + version: version + ) +end + +# @param [String] rule_name +# @param [Float] fixed_rate +# @param [Integer] reservoir_quota +# @param [Integer] reservoir_quota_ttl +# @param [Integer] interval +# @return [OpenTelemetry::Sampling::XRay::Client::SamplingTargetDocument] +def build_target_document( + rule_name: SecureRandom.uuid.to_s, + fixed_rate: rand, + reservoir_quota: rand(0..100), + reservoir_quota_ttl: rand(0..100), + interval: rand(0..100) +) + OpenTelemetry::Sampling::XRay::Client::SamplingTargetDocument.new( + rule_name: rule_name, + fixed_rate: fixed_rate, + reservoir_quota: reservoir_quota, + reservoir_quota_ttl: reservoir_quota_ttl, + interval: interval + ) +end diff --git a/sampling/xray/test/test_helper.rb b/sampling/xray/test/test_helper.rb new file mode 100644 index 000000000..c6fc4c50d --- /dev/null +++ b/sampling/xray/test/test_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require('bundler/setup') +Bundler.require(:default, :development, :test) + +require('opentelemetry-sampling-xray') +require('minitest/autorun') +require('webmock/minitest') +require_relative('test_factory') + +OpenTelemetry.logger = Logger.new($stderr, level: ENV.fetch('OTEL_LOG_LEVEL', 'info').to_sym)