diff --git a/.cspell.yml b/.cspell.yml index bf41f7e8c..d272d9254 100644 --- a/.cspell.yml +++ b/.cspell.yml @@ -68,6 +68,7 @@ ignoreWords: - rolldice - codegen - Dockerfiles + - otelconfig - opentelemetrybot - otelbot words: diff --git a/otelconfig/.rubocop.yml b/otelconfig/.rubocop.yml new file mode 100644 index 000000000..7e066c93b --- /dev/null +++ b/otelconfig/.rubocop.yml @@ -0,0 +1,25 @@ +inherit_from: ../contrib/rubocop.yml + +Metrics/AbcSize: + Max: 30 +Metrics/MethodLength: + Max: 50 +Metrics/PerceivedComplexity: + Max: 30 +Metrics/CyclomaticComplexity: + Max: 20 +Metrics/BlockLength: + Enabled: false +Metrics/ClassLength: + Enabled: false +Metrics/ModuleLength: + Max: 150 +Style/DocumentationMethod: + Exclude: + - lib/opentelemetry/constants/generated_constants.rb +Naming/MethodParameterName: + Exclude: + - lib/opentelemetry/constants/generated_constants.rb +Lint/StructNewOverride: + Exclude: + - lib/opentelemetry/constants/generated_constants.rb diff --git a/otelconfig/.yardopts b/otelconfig/.yardopts new file mode 100644 index 000000000..bb0d313dc --- /dev/null +++ b/otelconfig/.yardopts @@ -0,0 +1,9 @@ +--no-private +--title=OpenTelemetry Declarative Configuration +--markup=markdown +--main=README.md +./lib/opentelemetry/**/*.rb +./lib/opentelemetry.rb +- +README.md +CHANGELOG.md \ No newline at end of file diff --git a/otelconfig/CHANGELOG.md b/otelconfig/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/otelconfig/Gemfile b/otelconfig/Gemfile new file mode 100644 index 000000000..fb73416e1 --- /dev/null +++ b/otelconfig/Gemfile @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +source 'https://rubygems.org' + +gemspec + +group :test, :development do + gem 'minitest', '6.0.6' + gem 'rake', '13.4.2' + gem 'rubocop', '1.87.0' + gem 'rubocop-minitest', '0.39.1' + gem 'rubocop-performance', '1.26.1' + gem 'rubocop-rake', '0.7.1' + gem 'rubocop-rspec', '3.10.2' + gem 'simplecov', '0.22.0' + gem 'yard', '0.9.44' + + # Local path overrides for gems developed in this monorepo + gem 'opentelemetry-api', path: '../api', require: false + gem 'opentelemetry-common', path: '../common', require: false + gem 'opentelemetry-exporter-otlp', path: '../exporter/otlp', require: false + gem 'opentelemetry-exporter-otlp-common', path: '../exporter/otlp-common', require: false + gem 'opentelemetry-registry', path: '../registry', require: false + gem 'opentelemetry-sdk', path: '../sdk', require: false + gem 'opentelemetry-test-helpers', path: '../test_helpers', require: false + + gem 'opentelemetry-instrumentation-all', '0.94.0' + gem 'opentelemetry-propagator-google_cloud_trace_context', '0.4.0' + gem 'opentelemetry-propagator-ottrace', '0.25.0' + gem 'opentelemetry-propagator-xray', '0.27.0' + gem 'opentelemetry-resource-detector-aws', '0.7.0' + gem 'opentelemetry-resource-detector-azure', '0.4.0' + gem 'opentelemetry-resource-detector-container', '0.4.0' + gem 'opentelemetry-resource-detector-google_cloud_platform', '0.4.0' + + # Prevent bundler from downgrading google-protobuf to an incompatible + # pre-built binary (glibc) that does not run in Alpine (musl) containers. + gem 'google-protobuf', '3.25.8' + + if RUBY_VERSION >= '3.4' + gem 'base64' + gem 'bigdecimal' + gem 'mutex_m' + end + gem 'logger' if RUBY_VERSION >= '4.0.0' +end diff --git a/otelconfig/LICENSE b/otelconfig/LICENSE new file mode 100644 index 000000000..1ef7dad2c --- /dev/null +++ b/otelconfig/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/otelconfig/README.md b/otelconfig/README.md new file mode 100644 index 000000000..0d77fcb3e --- /dev/null +++ b/otelconfig/README.md @@ -0,0 +1,174 @@ +# opentelemetry-otelconfig + +The `opentelemetry-otelconfig` gem provides file-based, declarative configuration of the OpenTelemetry Ruby SDK from a single YAML file. It replaces the need to write programmatic setup code for common provider and exporter patterns. + +## 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, metrics, and logs from your application. You can analyze them using Prometheus, Jaeger, and other observability tools. + +## How does this gem fit in? + +The `opentelemetry-otelconfig` gem sits on top of the OpenTelemetry Ruby SDK. Instead of calling `OpenTelemetry::SDK.configure` with a block of Ruby code, you describe your desired configuration in a YAML file and let `opentelemetry-otelconfig` wire up all the opentelemetry components for you. + +It works with: + +- `opentelemetry-sdk` — tracing +- `opentelemetry-exporter-otlp` — OTLP HTTP exporters +- `opentelemetry-instrumentation-all` — auto-instrumentation + +## How do I get started? + +Install the gem using: + +```sh +gem install opentelemetry-otelconfig +``` + +Or, if you use [bundler][bundler-home], include `opentelemetry-otelconfig` in your `Gemfile`. + +### Automatic configuration via environment variable + +Set `OTEL_CONFIG_FILE` to the path of your YAML config file. Call `OpenTelemetry::OtelConfig.configure` early in your application; it returns a `RubySDK` value that you wire into the global OpenTelemetry state yourself. + +```sh +OTEL_CONFIG_FILE=/path/to/otel-config.yaml bundle exec ruby app.rb +``` + +```ruby +require 'opentelemetry-sdk' +require 'opentelemetry-otelconfig' + +sdk = OpenTelemetry::OtelConfig.configure +OpenTelemetry.tracer_provider = sdk.tracer_provider +OpenTelemetry.propagation = sdk.propagator if sdk&.propagator + +tracer = OpenTelemetry.tracer_provider.tracer('my_app', '1.0.0') +tracer.in_span('my-operation') do |span| + span.set_attribute('key', 'value') +end +``` + +If you have a config file path at hand, call `configure_from_file` instead: + +```ruby +sdk = OpenTelemetry::OtelConfig.configure_from_file('/path/to/otel-config.yaml') +OpenTelemetry.tracer_provider = sdk.tracer_provider +OpenTelemetry.propagation = sdk.propagator if sdk&.propagator +``` + +## YAML configuration reference + +See full configuration reference in [declarative-configuration](https://opentelemetry.io/docs/languages/sdk-configuration/declarative-configuration/) + +### Disabling the SDK + +Set `disabled: true` to keep all providers as no-ops without removing the config file. This is useful for running tests or CI pipelines without telemetry overhead. + +```yaml +file_format: "1.0" +disabled: true +``` + +### Resource attributes + +Attributes can be provided as a structured array, a comma-separated string, or both. When the same key appears in both, the `attributes` array takes priority. + +```yaml +resource: + attributes: + - name: service.name + value: "my-service" + - name: deployment.environment + value: "staging" + attributes_list: "service.namespace=my-namespace,service.version=1.0.0" +``` + +### Samplers + +| Sampler | YAML key | +| ------- | -------- | +| Always on | `always_on:` | +| Always off | `always_off:` | +| Trace-ID ratio | `trace_id_ratio_based: { ratio: 0.25 }` | +| Parent-based | `parent_based: { root: ... }` | + +```yaml +tracer_provider: + sampler: + parent_based: + root: + trace_id_ratio_based: + ratio: 0.1 + remote_parent_sampled: + always_on: + remote_parent_not_sampled: + always_off: + local_parent_sampled: + always_on: + local_parent_not_sampled: + always_off: +``` + +### Propagators + +Propagators can be listed either as a YAML array or as a comma-separated string. + +```yaml +# Array form +propagator: + composite: + - tracecontext: + - baggage: + +# String form (equivalent) +propagator: + composite_list: "tracecontext,baggage" +``` + +Supported propagator names: `tracecontext`, `baggage`, `b3`, `b3multi`, `jaeger`, `ottrace`, `xray`, `google_cloud_trace_context`. + +### Auto-instrumentation + +The `instrumentation/development` key configures auto-instrumentation. The `ruby:` sub-key maps snake_case library names to option hashes. + +```yaml +instrumentation/development: + ruby: + net_http: + untraced_hosts: + - localhost + rack: + untraced_endpoints: + - /healthz +``` + +Short names follow the snake_case convention of the instrumentation class suffix (e.g., `net_http` for `OpenTelemetry::Instrumentation::Net::HTTP`). + +## Examples + +A runnable example application is available in the [`example/`][example-dir] directory. It demonstrates traces configured from YAML with console output. + +```sh +cd otelconfig/example +bundle exec ruby app.rb +``` + +## How can I get involved? + +The `opentelemetry-otelconfig` gem source is [on github][repo-github], along with related gems including `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-otelconfig` 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/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/otelconfig/Rakefile b/otelconfig/Rakefile new file mode 100644 index 000000000..698e47f71 --- /dev/null +++ b/otelconfig/Rakefile @@ -0,0 +1,30 @@ +# 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' + +Dir.glob(File.join(__dir__, 'lib/tasks/**/*.rake')).each { |f| load f } + +RuboCop::RakeTask.new +YARD::Rake::YardocTask.new + +Rake::TestTask.new :test do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/**/*_test.rb'] +end + +default_tasks = + if RUBY_ENGINE == 'truffleruby' + %i[test] + else + %i[test rubocop yard] + end + +task default: default_tasks diff --git a/otelconfig/example/Gemfile b/otelconfig/example/Gemfile new file mode 100644 index 000000000..dea7fdef2 --- /dev/null +++ b/otelconfig/example/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +source 'https://rubygems.org' + +gem 'opentelemetry-api', path: '../../api' +gem 'opentelemetry-common', path: '../../common' +gem 'opentelemetry-exporter-otlp', path: '../../exporter/otlp' +gem 'opentelemetry-sdk', path: '../../sdk' +gem 'opentelemetry-otelconfig', path: '..' +gem 'opentelemetry-instrumentation-all' diff --git a/otelconfig/example/README.md b/otelconfig/example/README.md new file mode 100644 index 000000000..29cbf6a3f --- /dev/null +++ b/otelconfig/example/README.md @@ -0,0 +1,45 @@ +# Declarative Configuration Example + +This example shows how to configure the OpenTelemetry SDK (tracing) from a YAML +file using the `opentelemetry-otelconfig` gem — no programmatic +`OpenTelemetry::SDK.configure` block required. + +## Files + +| File | Purpose | +| ---- | ------- | +| `app.rb` | Example application — emits spans | +| `otel-config-console.yaml` | console-only exporter, works without a collector | +| `otel-config.yaml` | Include otlp_http exporter, need working collector | + +## Quick start (console output, no collector needed) + +```sh +# From this directory +bundle install +bundle exec ruby app.rb +``` + +You will see span output written to stdout. + +## How it works + +1. Set the `OTEL_CONFIG_FILE` environment variable to the path of your YAML file. +2. `require 'opentelemetry-otelconfig'` reads the file, parses it, and wires + up `TracerProvider`, propagators, and instrumentation — all in one step. +3. Use the standard OpenTelemetry API (`OpenTelemetry.tracer_provider`) as normal. + +If `OTEL_CONFIG_FILE` is not set, call `OpenTelemetry::OtelConfig.configure` +manually with a config hash, or configure programmatically using the SDK. + +## YAML config key reference + +| Section | Description | +| ------- | ----------- | +| `resource.attributes` | Service name, version, environment, and any custom resource attributes | +| `resource.attributes_list` | Comma-separated `key=value` pairs as an alternative to attributes array | +| `tracer_provider.processors` | `batch` or `simple` span processors with `console` or `otlp_http` exporters | +| `tracer_provider.sampler` | `always_on`, `always_off`, `trace_id_ratio_based`, or `parent_based` | +| `tracer_provider.limits` | Attribute, event, and link count/length limits | +| `propagator.composite` | Ordered list of propagators (`tracecontext`, `baggage`, `b3`, `b3multi`, `jaeger`, `xray`) | +| `instrumentation.general` | Enabled/disabled instrumentation libraries | diff --git a/otelconfig/example/app.rb b/otelconfig/example/app.rb new file mode 100755 index 000000000..161314d92 --- /dev/null +++ b/otelconfig/example/app.rb @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +ENV['OTEL_CONFIG_FILE'] ||= File.join(__dir__, 'otel-config-console.yaml') + +require 'bundler/setup' +require 'net/http' +require 'opentelemetry-sdk' +require 'opentelemetry-instrumentation-all' +require 'opentelemetry_otelconfig' + +sdk = OpenTelemetry::OtelConfig.configure +OpenTelemetry.tracer_provider = sdk.tracer_provider +OpenTelemetry.propagation = sdk.propagator if sdk&.propagator + +tracer = OpenTelemetry.tracer_provider.tracer('otelconfig-example', '1.0.0') + +tracer.in_span('process-order', attributes: { 'order.id' => 'ORD-001', 'order.items' => 3 }) do |span| + span.add_event('validation-started') + + # Simulate nested work in a child span + tracer.in_span('validate-inventory', attributes: { 'warehouse' => 'us-west-2' }) do |child| + sleep(0.01) # simulate I/O + child.set_attribute('inventory.available', true) + child.add_event('inventory-checked', attributes: { 'sku' => 'WIDGET-42', 'qty' => 10 }) + end + + span.add_event('validation-complete') + span.set_attribute('order.total_usd', 49.99) +end + +OpenTelemetry.tracer_provider.force_flush(timeout: 30) + +SITES = [ + { name: 'google.ca', uri: URI('https://www.google.ca') }, + { name: 'github.com', uri: URI('https://github.com') } +].freeze + +tracer.in_span('http-requests') do + SITES.each do |site| + tracer.in_span("GET #{site[:name]}", + attributes: { + 'http.method' => 'GET', + 'http.url' => site[:uri].to_s, + 'net.peer.name' => site[:name] + }) do |span| + response = Net::HTTP.get_response(site[:uri]) + span.set_attribute('http.status_code', response.code.to_i) + if response['content-length'] + span.set_attribute('http.response_content_length', + response['content-length'].to_i) + end + end + end +end + +OpenTelemetry.tracer_provider.shutdown diff --git a/otelconfig/example/otel-config-console.yaml b/otelconfig/example/otel-config-console.yaml new file mode 100644 index 000000000..bc67ae8b3 --- /dev/null +++ b/otelconfig/example/otel-config-console.yaml @@ -0,0 +1,46 @@ +file_format: "1.0" +disabled: false +log_level: info + +resource: + attributes: + - name: service.name + value: "declare-config-example" + - name: service.version + value: "1.0.0" + - name: deployment.environment + value: "development" + +tracer_provider: + processors: + - simple: + exporter: + console: + limits: + attribute_value_length_limit: 4096 + attribute_count_limit: 128 + event_count_limit: 128 + link_count_limit: 128 + sampler: + parent_based: + root: + always_on: + remote_parent_sampled: + always_on: + remote_parent_not_sampled: + always_off: + local_parent_sampled: + always_on: + local_parent_not_sampled: + always_off: + +propagator: + composite: + - tracecontext: + - baggage: + +instrumentation/development: + general: + net_http: + untraced_hosts: + - google.ca \ No newline at end of file diff --git a/otelconfig/example/otel-config.yaml b/otelconfig/example/otel-config.yaml new file mode 100644 index 000000000..b968b62c5 --- /dev/null +++ b/otelconfig/example/otel-config.yaml @@ -0,0 +1,64 @@ +file_format: "1.0" +disabled: false +log_level: info +attribute_limits: + attribute_value_length_limit: 4096 + attribute_count_limit: 128 +resource: + attributes: + - name: service.name + value: "test-ruby-declare-config" + - name: service.version + value: "1.0.0" + - name: deployment.environment + value: "staging" + attributes_list: "service.namespace=my-namespace,service.version=1.0.0" +tracer_provider: + processors: + - + batch: + schedule_delay: 5000 + export_timeout: 30000 + max_queue_size: 2048 + max_export_batch_size: 512 + exporter: + otlp_http: + endpoint: http://host.docker.internal:4328/v1/traces + compression: gzip + timeout: 10000 + - + simple: + exporter: + console: + limits: + attribute_value_length_limit: 4096 + attribute_count_limit: 128 + event_count_limit: 128 + link_count_limit: 128 + event_attribute_count_limit: 128 + link_attribute_count_limit: 128 + sampler: + parent_based: + root: + trace_id_ratio_based: + ratio: 1.0 + remote_parent_sampled: + always_on: + remote_parent_not_sampled: + always_off: + local_parent_sampled: + always_on: + local_parent_not_sampled: + always_off: +propagator: + composite: + - + tracecontext: + - + baggage: + composite_list: "tracecontext,baggage" +instrumentation/development: + general: + net_http: + untraced_hosts: + - google.ca \ No newline at end of file diff --git a/otelconfig/lib/opentelemetry/components/trace.rb b/otelconfig/lib/opentelemetry/components/trace.rb new file mode 100644 index 000000000..c0ed921f7 --- /dev/null +++ b/otelconfig/lib/opentelemetry/components/trace.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module OtelConfig + # Trace component builder for configuring TracerProvider from declarative config. + module Trace + module_function + + # Builds a TracerProvider from the parsed YAML tracer_provider config. + # Returns a noop-configured provider if config is nil. + def build_tracer_provider(config, resource) + return OpenTelemetry::Trace::TracerProvider.new unless config + + sampler = build_sampler(config.sampler) + span_limits = build_span_limits(config.limits) + + tp = OpenTelemetry::SDK::Trace::TracerProvider.new( + resource: resource, + sampler: sampler, + span_limits: span_limits + ) + + Array(config.processors).each do |proc_cfg| + processor = build_span_processor(proc_cfg) + tp.add_span_processor(processor) if processor + rescue StandardError => e + OpenTelemetry.logger.warn("Failed to build span processor: #{e.message}") + end + + tp + end + + # Builds a span processor (simple or batch) from config hash. + def build_span_processor(proc_cfg) + raise ArgumentError, 'must not specify multiple span processor type' if proc_cfg.batch && proc_cfg.simple + + if proc_cfg.batch + build_batch_span_processor(proc_cfg.batch) + elsif proc_cfg.simple + build_simple_span_processor(proc_cfg.simple) + else + raise ArgumentError, 'unsupported span processor type, must be one of simple or batch' + end + end + + # Builds a BatchSpanProcessor with exporter and optional tuning options. + def build_batch_span_processor(cfg) + exporter = build_span_exporter(cfg.exporter) + opts = { + schedule_delay: cfg.schedule_delay&.to_f, + exporter_timeout: cfg.export_timeout&.to_f, + max_queue_size: cfg.max_queue_size&.to_i, + max_export_batch_size: cfg.max_export_batch_size&.to_i + }.compact + + OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter, **opts) + end + + # Builds a SimpleSpanProcessor wrapping the configured exporter. + def build_simple_span_processor(cfg) + exporter = build_span_exporter(cfg.exporter) + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter) + end + + # Builds a span exporter from config; supports console and otlp_http. + def build_span_exporter(exp_cfg) + raise ArgumentError, 'no exporter config' unless exp_cfg + + configured = 0 + exporter = nil + + if exp_cfg.console + configured += 1 + exporter = OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new + end + + if exp_cfg.otlp_http + configured += 1 + exporter = build_otlp_http_span_exporter(exp_cfg.otlp_http) + end + + raise ArgumentError, 'must not specify multiple exporters' if configured > 1 + raise ArgumentError, 'no valid span exporter' if exporter.nil? + + exporter + end + + # Builds an OTLP HTTP span exporter from the given endpoint/headers config. + def build_otlp_http_span_exporter(cfg) + headers = headers_to_hash(cfg) + opts = { + endpoint: cfg.endpoint, + headers: headers.empty? ? nil : headers, + compression: cfg.compression, + timeout: cfg.timeout && cfg.timeout / 1000.0 # YAML ms → Ruby seconds + }.compact + + OpenTelemetry::Exporter::OTLP::Exporter.new(**opts) + end + + # Builds a sampler from config; defaults to ParentBased(ALWAYS_ON). + def build_sampler(sampler_cfg) + s = OpenTelemetry::SDK::Trace::Samplers + + # Default: parent-based with always_on root + return s.parent_based(root: s::ALWAYS_ON) unless sampler_cfg + + if sampler_cfg.parent_based + build_parent_based_sampler(sampler_cfg.parent_based) + elsif sampler_cfg.always_on + s::ALWAYS_ON + elsif sampler_cfg.always_off + s::ALWAYS_OFF + elsif sampler_cfg.trace_id_ratio_based + ratio = sampler_cfg.trace_id_ratio_based.ratio || 1.0 + s.trace_id_ratio_based(ratio.to_f) + else + s.parent_based(root: s::ALWAYS_ON) + end + end + + # Builds a ParentBased sampler with configurable root and remote/local delegates. + def build_parent_based_sampler(cfg) + s = OpenTelemetry::SDK::Trace::Samplers + + root = cfg.root ? build_sampler(cfg.root) : s::ALWAYS_ON + + opts = { + root: root, + remote_parent_sampled: cfg.remote_parent_sampled && build_sampler(cfg.remote_parent_sampled), + remote_parent_not_sampled: cfg.remote_parent_not_sampled && build_sampler(cfg.remote_parent_not_sampled), + local_parent_sampled: cfg.local_parent_sampled && build_sampler(cfg.local_parent_sampled), + local_parent_not_sampled: cfg.local_parent_not_sampled && build_sampler(cfg.local_parent_not_sampled) + }.compact + + s.parent_based(**opts) + end + + # Builds SpanLimits from config; returns the SDK default when config is nil. + def build_span_limits(limits_cfg) + return OpenTelemetry::SDK::Trace::SpanLimits::DEFAULT unless limits_cfg + + opts = { + attribute_count_limit: limits_cfg.attribute_count_limit, + attribute_length_limit: limits_cfg.attribute_value_length_limit, + event_count_limit: limits_cfg.event_count_limit, + link_count_limit: limits_cfg.link_count_limit, + event_attribute_count_limit: limits_cfg.event_attribute_count_limit, + link_attribute_count_limit: limits_cfg.link_attribute_count_limit + }.compact + + OpenTelemetry::SDK::Trace::SpanLimits.new(**opts) + end + + # Converts the OTLP exporter headers (array of NameStringValuePair structs) + # into a flat Hash. Falls back to the comma-separated headers_list string. + def headers_to_hash(cfg) + headers = {} + + Array(cfg.headers).each do |pair| + headers[pair.name] = pair.value if pair&.name + end + + if headers.empty? && cfg.headers_list.is_a?(String) + cfg.headers_list.split(',').each do |entry| + key, value = entry.strip.split('=', 2) + headers[key] = value if key && value + end + end + + headers + end + end + end +end diff --git a/otelconfig/lib/opentelemetry/constants/constants.rb b/otelconfig/lib/opentelemetry/constants/constants.rb new file mode 100644 index 000000000..75c8187cc --- /dev/null +++ b/otelconfig/lib/opentelemetry/constants/constants.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require_relative 'generated_constants' + +RubySDK = Struct.new( + :tracer_provider, + :meter_provider, + :logger_provider, + :resource, + :propagator +) diff --git a/otelconfig/lib/opentelemetry/constants/generated_constants.rb b/otelconfig/lib/opentelemetry/constants/generated_constants.rb new file mode 100644 index 000000000..3801e7996 --- /dev/null +++ b/otelconfig/lib/opentelemetry/constants/generated_constants.rb @@ -0,0 +1,1353 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# DO NOT EDIT — generated by `bundle exec rake generate:constants` +# Schema: open-telemetry/opentelemetry-configuration v1.0.0-rc.3 +# Structs: 74 (one per object definition in the schema) +# To regenerate: bundle exec rake generate:constants + +Aggregation = Struct.new( + :default, + :drop, + :explicit_bucket_histogram, + :base2_exponential_bucket_histogram, + :last_value, + :sum, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + default: h.key?('default'), + drop: h.key?('drop'), + explicit_bucket_histogram: ExplicitBucketHistogramAggregation.from_hash(h['explicit_bucket_histogram']), + base2_exponential_bucket_histogram: Base2ExponentialBucketHistogramAggregation.from_hash(h['base2_exponential_bucket_histogram']), + last_value: h.key?('last_value'), + sum: h.key?('sum') + ) + end +end + +AttributeLimits = Struct.new( + :attribute_value_length_limit, + :attribute_count_limit, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attribute_value_length_limit: h['attribute_value_length_limit'], + attribute_count_limit: h['attribute_count_limit'] + ) + end +end + +AttributeNameValue = Struct.new( + :name, + :value, + :type, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + value: h['value'], + type: h['type'] + ) + end +end + +Base2ExponentialBucketHistogramAggregation = Struct.new( + :max_scale, + :max_size, + :record_min_max, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + max_scale: h['max_scale'], + max_size: h['max_size'], + record_min_max: h['record_min_max'] + ) + end +end + +BatchLogRecordProcessor = Struct.new( + :schedule_delay, + :export_timeout, + :max_queue_size, + :max_export_batch_size, + :exporter, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + schedule_delay: h['schedule_delay'], + export_timeout: h['export_timeout'], + max_queue_size: h['max_queue_size'], + max_export_batch_size: h['max_export_batch_size'], + exporter: LogRecordExporter.from_hash(h['exporter']) + ) + end +end + +BatchSpanProcessor = Struct.new( + :schedule_delay, + :export_timeout, + :max_queue_size, + :max_export_batch_size, + :exporter, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + schedule_delay: h['schedule_delay'], + export_timeout: h['export_timeout'], + max_queue_size: h['max_queue_size'], + max_export_batch_size: h['max_export_batch_size'], + exporter: SpanExporter.from_hash(h['exporter']) + ) + end +end + +CardinalityLimits = Struct.new( + :default, + :counter, + :gauge, + :histogram, + :observable_counter, + :observable_gauge, + :observable_up_down_counter, + :up_down_counter, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + default: h['default'], + counter: h['counter'], + gauge: h['gauge'], + histogram: h['histogram'], + observable_counter: h['observable_counter'], + observable_gauge: h['observable_gauge'], + observable_up_down_counter: h['observable_up_down_counter'], + up_down_counter: h['up_down_counter'] + ) + end +end + +ConsoleMetricExporter = Struct.new( + :temporality_preference, + :default_histogram_aggregation, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + temporality_preference: h['temporality_preference'], + default_histogram_aggregation: h['default_histogram_aggregation'] + ) + end +end + +ExperimentalComposableParentThresholdSampler = Struct.new( + :root, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + root: ExperimentalComposableSampler.from_hash(h['root']) + ) + end +end + +ExperimentalComposableProbabilitySampler = Struct.new( + :ratio, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + ratio: h['ratio'] + ) + end +end + +ExperimentalComposableRuleBasedSampler = Struct.new( + :rules, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + rules: Array(h['rules']).filter_map { |e| ExperimentalComposableRuleBasedSamplerRule.from_hash(e) } + ) + end +end + +ExperimentalComposableRuleBasedSamplerRule = Struct.new( + :attribute_values, + :attribute_patterns, + :span_kinds, + :parent, + :sampler, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attribute_values: ExperimentalComposableRuleBasedSamplerRuleAttributeValues.from_hash(h['attribute_values']), + attribute_patterns: ExperimentalComposableRuleBasedSamplerRuleAttributePatterns.from_hash(h['attribute_patterns']), + span_kinds: h['span_kinds'], + parent: h['parent'], + sampler: ExperimentalComposableSampler.from_hash(h['sampler']) + ) + end +end + +ExperimentalComposableRuleBasedSamplerRuleAttributePatterns = Struct.new( + :key, + :included, + :excluded, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + key: h['key'], + included: h['included'], + excluded: h['excluded'] + ) + end +end + +ExperimentalComposableRuleBasedSamplerRuleAttributeValues = Struct.new( + :key, + :values, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + key: h['key'], + values: h['values'] + ) + end +end + +ExperimentalComposableSampler = Struct.new( + :always_off, + :always_on, + :parent_threshold, + :probability, + :rule_based, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + always_off: h.key?('always_off'), + always_on: h.key?('always_on'), + parent_threshold: ExperimentalComposableParentThresholdSampler.from_hash(h['parent_threshold']), + probability: ExperimentalComposableProbabilitySampler.from_hash(h['probability']), + rule_based: ExperimentalComposableRuleBasedSampler.from_hash(h['rule_based']), + additional_properties: h.reject { |k, _| %w[always_off always_on parent_threshold probability rule_based].include?(k) } + ) + end +end + +ExperimentalGeneralInstrumentation = Struct.new( + :peer, + :http, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + peer: ExperimentalPeerInstrumentation.from_hash(h['peer']), + http: ExperimentalHttpInstrumentation.from_hash(h['http']) + ) + end +end + +ExperimentalHttpClientInstrumentation = Struct.new( + :request_captured_headers, + :response_captured_headers, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + request_captured_headers: h['request_captured_headers'], + response_captured_headers: h['response_captured_headers'] + ) + end +end + +ExperimentalHttpInstrumentation = Struct.new( + :client, + :server, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + client: ExperimentalHttpClientInstrumentation.from_hash(h['client']), + server: ExperimentalHttpServerInstrumentation.from_hash(h['server']) + ) + end +end + +ExperimentalHttpServerInstrumentation = Struct.new( + :request_captured_headers, + :response_captured_headers, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + request_captured_headers: h['request_captured_headers'], + response_captured_headers: h['response_captured_headers'] + ) + end +end + +ExperimentalInstrumentation = Struct.new( + :general, + :cpp, + :dotnet, + :erlang, + :go, + :java, + :js, + :php, + :python, + :ruby, + :rust, + :swift, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + general: ExperimentalGeneralInstrumentation.from_hash(h['general']), + cpp: h['cpp'], + dotnet: h['dotnet'], + erlang: h['erlang'], + go: h['go'], + java: h['java'], + js: h['js'], + php: h['php'], + python: h['python'], + ruby: h['ruby'], + rust: h['rust'], + swift: h['swift'] + ) + end +end + +ExperimentalJaegerRemoteSampler = Struct.new( + :endpoint, + :interval, + :initial_sampler, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + endpoint: h['endpoint'], + interval: h['interval'], + initial_sampler: Sampler.from_hash(h['initial_sampler']) + ) + end +end + +ExperimentalLoggerConfig = Struct.new( + :disabled, + :minimum_severity, + :trace_based, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + disabled: h['disabled'], + minimum_severity: h['minimum_severity'], + trace_based: h['trace_based'] + ) + end +end + +ExperimentalLoggerConfigurator = Struct.new( + :default_config, + :loggers, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + default_config: ExperimentalLoggerConfig.from_hash(h['default_config']), + loggers: Array(h['loggers']).filter_map { |e| ExperimentalLoggerMatcherAndConfig.from_hash(e) } + ) + end +end + +ExperimentalLoggerMatcherAndConfig = Struct.new( + :name, + :config, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + config: ExperimentalLoggerConfig.from_hash(h['config']) + ) + end +end + +ExperimentalMeterConfig = Struct.new( + :disabled, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + disabled: h['disabled'] + ) + end +end + +ExperimentalMeterConfigurator = Struct.new( + :default_config, + :meters, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + default_config: ExperimentalMeterConfig.from_hash(h['default_config']), + meters: Array(h['meters']).filter_map { |e| ExperimentalMeterMatcherAndConfig.from_hash(e) } + ) + end +end + +ExperimentalMeterMatcherAndConfig = Struct.new( + :name, + :config, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + config: ExperimentalMeterConfig.from_hash(h['config']) + ) + end +end + +ExperimentalOtlpFileExporter = Struct.new( + :output_stream, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + output_stream: h['output_stream'] + ) + end +end + +ExperimentalOtlpFileMetricExporter = Struct.new( + :output_stream, + :temporality_preference, + :default_histogram_aggregation, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + output_stream: h['output_stream'], + temporality_preference: h['temporality_preference'], + default_histogram_aggregation: h['default_histogram_aggregation'] + ) + end +end + +ExperimentalPeerInstrumentation = Struct.new( + :service_mapping, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + service_mapping: Array(h['service_mapping']).filter_map { |e| ExperimentalPeerServiceMapping.from_hash(e) } + ) + end +end + +ExperimentalPeerServiceMapping = Struct.new( + :peer, + :service, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + peer: h['peer'], + service: h['service'] + ) + end +end + +ExperimentalProbabilitySampler = Struct.new( + :ratio, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + ratio: h['ratio'] + ) + end +end + +ExperimentalPrometheusMetricExporter = Struct.new( + :host, + :port, + :without_scope_info, + :without_target_info, + :with_resource_constant_labels, + :translation_strategy, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + host: h['host'], + port: h['port'], + without_scope_info: h['without_scope_info'], + without_target_info: h['without_target_info'], + with_resource_constant_labels: IncludeExclude.from_hash(h['with_resource_constant_labels']), + translation_strategy: h['translation_strategy'] + ) + end +end + +ExperimentalResourceDetection = Struct.new( + :attributes, + :detectors, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attributes: IncludeExclude.from_hash(h['attributes']), + detectors: Array(h['detectors']).filter_map { |e| ExperimentalResourceDetector.from_hash(e) } + ) + end +end + +ExperimentalResourceDetector = Struct.new( + :container, + :host, + :process, + :service, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + container: h.key?('container'), + host: h.key?('host'), + process: h.key?('process'), + service: h.key?('service'), + additional_properties: h.reject { |k, _| %w[container host process service].include?(k) } + ) + end +end + +ExperimentalTracerConfig = Struct.new( + :disabled, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + disabled: h['disabled'] + ) + end +end + +ExperimentalTracerConfigurator = Struct.new( + :default_config, + :tracers, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + default_config: ExperimentalTracerConfig.from_hash(h['default_config']), + tracers: Array(h['tracers']).filter_map { |e| ExperimentalTracerMatcherAndConfig.from_hash(e) } + ) + end +end + +ExperimentalTracerMatcherAndConfig = Struct.new( + :name, + :config, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + config: ExperimentalTracerConfig.from_hash(h['config']) + ) + end +end + +ExplicitBucketHistogramAggregation = Struct.new( + :boundaries, + :record_min_max, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + boundaries: h['boundaries'], + record_min_max: h['record_min_max'] + ) + end +end + +GrpcTls = Struct.new( + :ca_file, + :key_file, + :cert_file, + :insecure, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + ca_file: h['ca_file'], + key_file: h['key_file'], + cert_file: h['cert_file'], + insecure: h['insecure'] + ) + end +end + +HttpTls = Struct.new( + :ca_file, + :key_file, + :cert_file, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + ca_file: h['ca_file'], + key_file: h['key_file'], + cert_file: h['cert_file'] + ) + end +end + +IncludeExclude = Struct.new( + :included, + :excluded, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + included: h['included'], + excluded: h['excluded'] + ) + end +end + +LogRecordExporter = Struct.new( + :otlp_http, + :otlp_grpc, + :otlp_file_development, + :console, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + otlp_http: OtlpHttpExporter.from_hash(h['otlp_http']), + otlp_grpc: OtlpGrpcExporter.from_hash(h['otlp_grpc']), + otlp_file_development: ExperimentalOtlpFileExporter.from_hash(h['otlp_file/development']), + console: h.key?('console'), + additional_properties: h.reject { |k, _| ['otlp_http', 'otlp_grpc', 'otlp_file/development', 'console'].include?(k) } + ) + end +end + +LogRecordLimits = Struct.new( + :attribute_value_length_limit, + :attribute_count_limit, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attribute_value_length_limit: h['attribute_value_length_limit'], + attribute_count_limit: h['attribute_count_limit'] + ) + end +end + +LogRecordProcessor = Struct.new( + :batch, + :simple, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + batch: BatchLogRecordProcessor.from_hash(h['batch']), + simple: SimpleLogRecordProcessor.from_hash(h['simple']), + additional_properties: h.reject { |k, _| %w[batch simple].include?(k) } + ) + end +end + +LoggerProvider = Struct.new( + :processors, + :limits, + :logger_configurator_development, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + processors: Array(h['processors']).filter_map { |e| LogRecordProcessor.from_hash(e) }, + limits: LogRecordLimits.from_hash(h['limits']), + logger_configurator_development: ExperimentalLoggerConfigurator.from_hash(h['logger_configurator/development']) + ) + end +end + +MeterProvider = Struct.new( + :readers, + :views, + :exemplar_filter, + :meter_configurator_development, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + readers: Array(h['readers']).filter_map { |e| MetricReader.from_hash(e) }, + views: Array(h['views']).filter_map { |e| View.from_hash(e) }, + exemplar_filter: h['exemplar_filter'], + meter_configurator_development: ExperimentalMeterConfigurator.from_hash(h['meter_configurator/development']) + ) + end +end + +MetricProducer = Struct.new( + :opencensus, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + opencensus: h.key?('opencensus'), + additional_properties: h.reject { |k, _| ['opencensus'].include?(k) } + ) + end +end + +MetricReader = Struct.new( + :periodic, + :pull, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + periodic: PeriodicMetricReader.from_hash(h['periodic']), + pull: PullMetricReader.from_hash(h['pull']) + ) + end +end + +NameStringValuePair = Struct.new( + :name, + :value, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + value: h['value'] + ) + end +end + +OpenTelemetryConfiguration = Struct.new( + :file_format, + :disabled, + :log_level, + :attribute_limits, + :logger_provider, + :meter_provider, + :propagator, + :tracer_provider, + :resource, + :instrumentation_development, + :distribution, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + file_format: h['file_format'], + disabled: h['disabled'], + log_level: h['log_level'], + attribute_limits: AttributeLimits.from_hash(h['attribute_limits']), + logger_provider: LoggerProvider.from_hash(h['logger_provider']), + meter_provider: MeterProvider.from_hash(h['meter_provider']), + propagator: Propagator.from_hash(h['propagator']), + tracer_provider: TracerProvider.from_hash(h['tracer_provider']), + resource: Resource.from_hash(h['resource']), + instrumentation_development: ExperimentalInstrumentation.from_hash(h['instrumentation/development']), + distribution: h['distribution'], + additional_properties: h.reject { |k, _| ['file_format', 'disabled', 'log_level', 'attribute_limits', 'logger_provider', 'meter_provider', 'propagator', 'tracer_provider', 'resource', 'instrumentation/development', 'distribution'].include?(k) } + ) + end +end + +OtlpGrpcExporter = Struct.new( + :endpoint, + :tls, + :headers, + :headers_list, + :compression, + :timeout, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + endpoint: h['endpoint'], + tls: GrpcTls.from_hash(h['tls']), + headers: Array(h['headers']).filter_map { |e| NameStringValuePair.from_hash(e) }, + headers_list: h['headers_list'], + compression: h['compression'], + timeout: h['timeout'] + ) + end +end + +OtlpGrpcMetricExporter = Struct.new( + :endpoint, + :tls, + :headers, + :headers_list, + :compression, + :timeout, + :temporality_preference, + :default_histogram_aggregation, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + endpoint: h['endpoint'], + tls: GrpcTls.from_hash(h['tls']), + headers: Array(h['headers']).filter_map { |e| NameStringValuePair.from_hash(e) }, + headers_list: h['headers_list'], + compression: h['compression'], + timeout: h['timeout'], + temporality_preference: h['temporality_preference'], + default_histogram_aggregation: h['default_histogram_aggregation'] + ) + end +end + +OtlpHttpExporter = Struct.new( + :endpoint, + :tls, + :headers, + :headers_list, + :compression, + :timeout, + :encoding, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + endpoint: h['endpoint'], + tls: HttpTls.from_hash(h['tls']), + headers: Array(h['headers']).filter_map { |e| NameStringValuePair.from_hash(e) }, + headers_list: h['headers_list'], + compression: h['compression'], + timeout: h['timeout'], + encoding: h['encoding'] + ) + end +end + +OtlpHttpMetricExporter = Struct.new( + :endpoint, + :tls, + :headers, + :headers_list, + :compression, + :timeout, + :encoding, + :temporality_preference, + :default_histogram_aggregation, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + endpoint: h['endpoint'], + tls: HttpTls.from_hash(h['tls']), + headers: Array(h['headers']).filter_map { |e| NameStringValuePair.from_hash(e) }, + headers_list: h['headers_list'], + compression: h['compression'], + timeout: h['timeout'], + encoding: h['encoding'], + temporality_preference: h['temporality_preference'], + default_histogram_aggregation: h['default_histogram_aggregation'] + ) + end +end + +ParentBasedSampler = Struct.new( + :root, + :remote_parent_sampled, + :remote_parent_not_sampled, + :local_parent_sampled, + :local_parent_not_sampled, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + root: Sampler.from_hash(h['root']), + remote_parent_sampled: Sampler.from_hash(h['remote_parent_sampled']), + remote_parent_not_sampled: Sampler.from_hash(h['remote_parent_not_sampled']), + local_parent_sampled: Sampler.from_hash(h['local_parent_sampled']), + local_parent_not_sampled: Sampler.from_hash(h['local_parent_not_sampled']) + ) + end +end + +PeriodicMetricReader = Struct.new( + :interval, + :timeout, + :exporter, + :producers, + :cardinality_limits, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + interval: h['interval'], + timeout: h['timeout'], + exporter: PushMetricExporter.from_hash(h['exporter']), + producers: Array(h['producers']).filter_map { |e| MetricProducer.from_hash(e) }, + cardinality_limits: CardinalityLimits.from_hash(h['cardinality_limits']) + ) + end +end + +Propagator = Struct.new( + :composite, + :composite_list, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + composite: Array(h['composite']).filter_map { |e| TextMapPropagator.from_hash(e) }, + composite_list: h['composite_list'] + ) + end +end + +PullMetricExporter = Struct.new( + :prometheus_development, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + prometheus_development: ExperimentalPrometheusMetricExporter.from_hash(h['prometheus/development']), + additional_properties: h.reject { |k, _| ['prometheus/development'].include?(k) } + ) + end +end + +PullMetricReader = Struct.new( + :exporter, + :producers, + :cardinality_limits, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + exporter: PullMetricExporter.from_hash(h['exporter']), + producers: Array(h['producers']).filter_map { |e| MetricProducer.from_hash(e) }, + cardinality_limits: CardinalityLimits.from_hash(h['cardinality_limits']) + ) + end +end + +PushMetricExporter = Struct.new( + :otlp_http, + :otlp_grpc, + :otlp_file_development, + :console, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + otlp_http: OtlpHttpMetricExporter.from_hash(h['otlp_http']), + otlp_grpc: OtlpGrpcMetricExporter.from_hash(h['otlp_grpc']), + otlp_file_development: ExperimentalOtlpFileMetricExporter.from_hash(h['otlp_file/development']), + console: ConsoleMetricExporter.from_hash(h['console']), + additional_properties: h.reject { |k, _| ['otlp_http', 'otlp_grpc', 'otlp_file/development', 'console'].include?(k) } + ) + end +end + +Resource = Struct.new( + :attributes, + :detection_development, + :schema_url, + :attributes_list, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attributes: Array(h['attributes']).filter_map { |e| AttributeNameValue.from_hash(e) }, + detection_development: ExperimentalResourceDetection.from_hash(h['detection/development']), + schema_url: h['schema_url'], + attributes_list: h['attributes_list'] + ) + end +end + +Sampler = Struct.new( + :always_off, + :always_on, + :composite_development, + :jaeger_remote_development, + :parent_based, + :probability_development, + :trace_id_ratio_based, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + always_off: h.key?('always_off'), + always_on: h.key?('always_on'), + composite_development: ExperimentalComposableSampler.from_hash(h['composite/development']), + jaeger_remote_development: ExperimentalJaegerRemoteSampler.from_hash(h['jaeger_remote/development']), + parent_based: ParentBasedSampler.from_hash(h['parent_based']), + probability_development: ExperimentalProbabilitySampler.from_hash(h['probability/development']), + trace_id_ratio_based: TraceIdRatioBasedSampler.from_hash(h['trace_id_ratio_based']), + additional_properties: h.reject { |k, _| ['always_off', 'always_on', 'composite/development', 'jaeger_remote/development', 'parent_based', 'probability/development', 'trace_id_ratio_based'].include?(k) } + ) + end +end + +SimpleLogRecordProcessor = Struct.new( + :exporter, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + exporter: LogRecordExporter.from_hash(h['exporter']) + ) + end +end + +SimpleSpanProcessor = Struct.new( + :exporter, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + exporter: SpanExporter.from_hash(h['exporter']) + ) + end +end + +SpanExporter = Struct.new( + :otlp_http, + :otlp_grpc, + :otlp_file_development, + :console, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + otlp_http: OtlpHttpExporter.from_hash(h['otlp_http']), + otlp_grpc: OtlpGrpcExporter.from_hash(h['otlp_grpc']), + otlp_file_development: ExperimentalOtlpFileExporter.from_hash(h['otlp_file/development']), + console: h.key?('console'), + additional_properties: h.reject { |k, _| ['otlp_http', 'otlp_grpc', 'otlp_file/development', 'console'].include?(k) } + ) + end +end + +SpanLimits = Struct.new( + :attribute_value_length_limit, + :attribute_count_limit, + :event_count_limit, + :link_count_limit, + :event_attribute_count_limit, + :link_attribute_count_limit, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + attribute_value_length_limit: h['attribute_value_length_limit'], + attribute_count_limit: h['attribute_count_limit'], + event_count_limit: h['event_count_limit'], + link_count_limit: h['link_count_limit'], + event_attribute_count_limit: h['event_attribute_count_limit'], + link_attribute_count_limit: h['link_attribute_count_limit'] + ) + end +end + +SpanProcessor = Struct.new( + :batch, + :simple, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + batch: BatchSpanProcessor.from_hash(h['batch']), + simple: SimpleSpanProcessor.from_hash(h['simple']), + additional_properties: h.reject { |k, _| %w[batch simple].include?(k) } + ) + end +end + +TextMapPropagator = Struct.new( + :tracecontext, + :baggage, + :b3, + :b3multi, + :jaeger, + :ottrace, + :additional_properties, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + tracecontext: h.key?('tracecontext'), + baggage: h.key?('baggage'), + b3: h.key?('b3'), + b3multi: h.key?('b3multi'), + jaeger: h.key?('jaeger'), + ottrace: h.key?('ottrace'), + additional_properties: h.reject { |k, _| %w[tracecontext baggage b3 b3multi jaeger ottrace].include?(k) } + ) + end +end + +TraceIdRatioBasedSampler = Struct.new( + :ratio, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + ratio: h['ratio'] + ) + end +end + +TracerProvider = Struct.new( + :processors, + :limits, + :sampler, + :tracer_configurator_development, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + processors: Array(h['processors']).filter_map { |e| SpanProcessor.from_hash(e) }, + limits: SpanLimits.from_hash(h['limits']), + sampler: Sampler.from_hash(h['sampler']), + tracer_configurator_development: ExperimentalTracerConfigurator.from_hash(h['tracer_configurator/development']) + ) + end +end + +View = Struct.new( + :selector, + :stream, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + selector: ViewSelector.from_hash(h['selector']), + stream: ViewStream.from_hash(h['stream']) + ) + end +end + +ViewSelector = Struct.new( + :instrument_name, + :instrument_type, + :unit, + :meter_name, + :meter_version, + :meter_schema_url, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + instrument_name: h['instrument_name'], + instrument_type: h['instrument_type'], + unit: h['unit'], + meter_name: h['meter_name'], + meter_version: h['meter_version'], + meter_schema_url: h['meter_schema_url'] + ) + end +end + +ViewStream = Struct.new( + :name, + :description, + :aggregation, + :aggregation_cardinality_limit, + :attribute_keys, + keyword_init: true +) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + name: h['name'], + description: h['description'], + aggregation: Aggregation.from_hash(h['aggregation']), + aggregation_cardinality_limit: h['aggregation_cardinality_limit'], + attribute_keys: IncludeExclude.from_hash(h['attribute_keys']) + ) + end +end diff --git a/otelconfig/lib/opentelemetry/otelconfig.rb b/otelconfig/lib/opentelemetry/otelconfig.rb new file mode 100644 index 000000000..56078077a --- /dev/null +++ b/otelconfig/lib/opentelemetry/otelconfig.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'date' +require 'yaml' +require 'opentelemetry/components/trace' + +require_relative 'otelconfig/instrumentation' +require_relative 'otelconfig/propagation' +require_relative 'otelconfig/resource' +require_relative 'constants/constants' + +module OpenTelemetry + # OtelConfig module handles declarative configuration of OpenTelemetry components + # from YAML files. + module OtelConfig + ENV_CONFIG_FILE = 'OTEL_CONFIG_FILE' + + class << self + # Entry point + def configure + config_path = ENV[ENV_CONFIG_FILE] + + if config_path.to_s.empty? + OpenTelemetry.logger.info('No OTEL_CONFIG_FILE defined.') + else + config = parse_config_file(config_path) + apply(config) + end + end + + # Configure directly from a file path (for testing or explicit setup). + def configure_from_file(path) + config = parse_config_file(path) + apply(config) + end + + private + + def apply(config) + return if config.nil? + + unless defined?(OpenTelemetry::SDK) + warn '[opentelemetry-otelconfig] opentelemetry-sdk is not loaded. ' \ + 'Add `gem "opentelemetry-sdk"` to your Gemfile.' + return + end + + if config.disabled + OpenTelemetry.logger.info('OpenTelemetry SDK disabled by configuration.') + else + resource = build_resource(config.resource) + tracer_provider = Trace.build_tracer_provider(config.tracer_provider, resource) + + propagators = configure_propagation(config.propagator) + + configure_instrumentation(config.instrumentation_development) + + RubySDK.new( + tracer_provider: tracer_provider, + propagator: propagators, + resource: resource + ) + end + end + + def parse_config_file(path) + content = File.read(path) + OpenTelemetryConfiguration.from_hash(YAML.safe_load(content, permitted_classes: [Date, Time])) + rescue Errno::ENOENT => e + OpenTelemetry.logger.error("Config file not found: #{e.message}") + nil + rescue Psych::SyntaxError => e + OpenTelemetry.logger.error("YAML parse error: #{e.message}") + nil + end + end + end +end diff --git a/otelconfig/lib/opentelemetry/otelconfig/instrumentation.rb b/otelconfig/lib/opentelemetry/otelconfig/instrumentation.rb new file mode 100644 index 000000000..824666803 --- /dev/null +++ b/otelconfig/lib/opentelemetry/otelconfig/instrumentation.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + # OtelConfig module — instrumentation configuration helpers. + module OtelConfig + class << self + # Installs instrumentation libraries from the registry. + def configure_instrumentation(instrumentation_cfg) + config_map = build_instrumentation_config_map(instrumentation_cfg) + OpenTelemetry::Instrumentation.registry.install_all(config_map) + config_map + rescue NameError + OpenTelemetry.logger.warn('opentelemetry-instrumentation-all not available; skipping instrumentation install.') + end + + # Transforms the YAML instrumentation config into the flat hash that + # install_all expects: { 'OpenTelemetry::Instrumentation::Foo' => { opt: val } } + # + # Accepts either the schema-generated ExperimentalInstrumentation struct + # (reading its +ruby+ language map) or a raw Hash with a 'ruby' key. + def build_instrumentation_config_map(instrumentation_cfg) + ruby_instrumentation = + if instrumentation_cfg.is_a?(Hash) + instrumentation_cfg['ruby'] + elsif instrumentation_cfg.respond_to?(:ruby) + instrumentation_cfg.ruby + end + return {} unless ruby_instrumentation.is_a?(Hash) + + name_map = build_instrumentation_name_map + ruby_instrumentation.each_with_object({}) do |(short_name, options), result| + full_name = name_map[short_name.to_s] + unless full_name + OpenTelemetry.logger.warn("Declarative config: unknown instrumentation short name '#{short_name}' — skipping.") + next + end + result[full_name] = options.is_a?(Hash) ? options.transform_keys(&:to_sym) : {} + end + end + + # Builds a lookup table: snake_case_short_name => full_class_name + # e.g. 'net_http' => 'OpenTelemetry::Instrumentation::Net::HTTP' + def build_instrumentation_name_map + registry = OpenTelemetry::Instrumentation.registry + registry.instance_variable_get(:@instrumentation).each_with_object({}) do |inst_class, map| + inst = inst_class.instance + short = inst.name.delete_prefix('OpenTelemetry::Instrumentation::') + .gsub('::', '_') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') # this is for case like AwsLambda -> aws_lambda + .downcase + map[short] = inst.name + end + rescue StandardError + {} + end + end + end +end diff --git a/otelconfig/lib/opentelemetry/otelconfig/propagation.rb b/otelconfig/lib/opentelemetry/otelconfig/propagation.rb new file mode 100644 index 000000000..2eedb38f5 --- /dev/null +++ b/otelconfig/lib/opentelemetry/otelconfig/propagation.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + # OtelConfig module — propagation configuration helpers. + module OtelConfig + class << self + # Configures the global text-map propagator from the propagator_cfg hash. + def configure_propagation(propagator_cfg) + return unless propagator_cfg + + propagators = extract_propagator_names(propagator_cfg) + return if propagators.empty? + + composite_propagators = propagators.filter_map { |name| resolve_propagator(name) } + return if composite_propagators.empty? + + OpenTelemetry::Context::Propagation::CompositeTextMapPropagator.compose_propagators(composite_propagators) + end + + # Extracts an ordered, deduplicated list of propagator name strings from + # the config. Names from +composite+ come first, then any additional names + # from +composite_list+ that were not already included. + def extract_propagator_names(cfg) + propagators = [] + Array(cfg.composite).each do |entry| + next unless entry + + entry.members.each do |m| + next if m == :additional_properties + + propagators << m.to_s if entry[m] + end + (entry.additional_properties || {}).each_key { |k| propagators << k.to_s } + end + propagators += cfg.composite_list.split(',').map(&:strip) if cfg.composite_list.is_a?(String) + propagators.uniq + end + + # Returns a propagator instance for the given name, or nil with a warning. + def resolve_propagator(name) + case name + when 'tracecontext' + OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator + when 'baggage' + OpenTelemetry::Baggage::Propagation.text_map_propagator + when 'b3' + const_get_propagator('OpenTelemetry::Propagator::B3::Single') + when 'b3multi' + const_get_propagator('OpenTelemetry::Propagator::B3::Multi') + when 'jaeger' + const_get_propagator('OpenTelemetry::Propagator::Jaeger') + when 'ottrace' + const_get_propagator('OpenTelemetry::Propagator::OTTrace') + when 'xray' + const_get_propagator('OpenTelemetry::Propagator::XRay') + when 'google_cloud_trace_context' + const_get_propagator('OpenTelemetry::Propagator::GoogleCloudTraceContext') + else + OpenTelemetry.logger.warn("Unknown propagator: #{name}") + nil + end + end + + # Looks up a propagator class by fully-qualified name and returns its text_map_propagator. + def const_get_propagator(class_name) + Kernel.const_get(class_name).text_map_propagator + rescue NameError + OpenTelemetry.logger.warn("Propagator #{class_name} not available — is the gem installed?") + nil + end + end + end +end diff --git a/otelconfig/lib/opentelemetry/otelconfig/resource.rb b/otelconfig/lib/opentelemetry/otelconfig/resource.rb new file mode 100644 index 000000000..9f2cc7802 --- /dev/null +++ b/otelconfig/lib/opentelemetry/otelconfig/resource.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + # OtelConfig module — resource configuration helpers. + module OtelConfig + class << self + # Priority: attributes > attribute_list > detected > base + def build_resource(resource_cfg) + base = OpenTelemetry::SDK::Resources::Resource.default + + return base unless resource_cfg + + detected = build_detected_attributes(resource_cfg.detection_development) + + explicit = {} + Array(resource_cfg.attributes).each do |attr| + next unless attr.name && !attr.value.nil? + + explicit[attr.name] = coerce_attribute_value(attr.value, attr.type) + end + + if resource_cfg.attributes_list.is_a?(String) + resource_cfg.attributes_list.split(',').each do |pair| + key, value = pair.strip.split('=', 2) + explicit[key] ||= value if key && value + end + end + + OpenTelemetry.logger.warn('OtelConfig: schema_url is supported; ignoring.') if resource_cfg.schema_url + + attrs = detected.merge(explicit) + custom = OpenTelemetry::SDK::Resources::Resource.create(attrs) + base.merge(custom) + end + + private + + # type coercion + def coerce_attribute_value(value, type) + case type + when 'string' then value.to_s + when 'bool' then coerce_bool(value) + when 'int' then Integer(value) + when 'double' then Float(value) + when 'string_array' then Array(value).map(&:to_s) + when 'bool_array' then Array(value).map { |v| coerce_bool(v) } + when 'int_array' then Array(value).map { |v| Integer(v) } + when 'double_array' then Array(value).map { |v| Float(v) } + else value # no type field → use the YAML-parsed value as-is + end + end + + def coerce_bool(value) + case value + when true, 'true', 1 then true + when false, 'false', 0 then false + else !!value + end + end + + # Extract the attributes from an ExperimentalResourceDetection struct. + def build_detected_attributes(detection_cfg) + return {} unless detection_cfg + + included_patterns = Array(detection_cfg.attributes&.included) + excluded_patterns = Array(detection_cfg.attributes&.excluded) + detector_names = detector_names_from(detection_cfg.detectors) + + raw = detector_names.each_with_object({}) do |name, attrs| + attrs.merge!(run_detector(name).attribute_enumerator.to_h) + end + + # File.fnmatch: * matches any chars except /, so "process.*" covers + # "process.pid", "process.runtime.name", etc. + raw.select do |key, _| + included = included_patterns.empty? || included_patterns.any? { |pat| File.fnmatch(pat, key) } + excluded = excluded_patterns.any? { |pat| File.fnmatch(pat, key) } + included && !excluded + end + end + + # Flattens an array of ExperimentalResourceDetector structs into the list + # of detector names whose presence flag is set (e.g. container, host). + def detector_names_from(detectors) + Array(detectors).flat_map do |detector| + next [] unless detector + + names = detector.members.filter_map do |m| + next if m == :additional_properties + + m.to_s if detector[m] + end + names + Array(detector.additional_properties&.keys).map(&:to_s) + end + end + + # Returns a Resource for the given detector name. + def run_detector(name) + case name + when 'container' + detect_resource('OpenTelemetry::Resource::Detector::Container') + when 'aws' + # Run all AWS sub-detectors; each returns an empty resource if not on that platform. + detect_resource('OpenTelemetry::Resource::Detector::AWS', %i[ec2 ecs eks lambda]) + when 'azure' + detect_resource('OpenTelemetry::Resource::Detector::Azure') + when 'google_cloud_platform' + detect_resource('OpenTelemetry::Resource::Detector::GoogleCloudPlatform') + else + OpenTelemetry.logger.warn("OtelConfig: unknown resource detector '#{name}'; skipping.") + OpenTelemetry::SDK::Resources::Resource.create({}) + end + end + + # Looks up a resource detector class by fully-qualified name and calls detect. + def detect_resource(class_name, *args) + Kernel.const_get(class_name).detect(*args) + rescue NameError + OpenTelemetry.logger.warn("OtelConfig: resource detector '#{class_name}' is not available — is the gem installed?") + OpenTelemetry::SDK::Resources::Resource.create({}) + end + end + end +end diff --git a/otelconfig/lib/opentelemetry/otelconfig/version.rb b/otelconfig/lib/opentelemetry/otelconfig/version.rb new file mode 100644 index 000000000..7daa1850c --- /dev/null +++ b/otelconfig/lib/opentelemetry/otelconfig/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +module OpenTelemetry + module OtelConfig + VERSION = '0.1.0' + end +end diff --git a/otelconfig/lib/opentelemetry_otelconfig.rb b/otelconfig/lib/opentelemetry_otelconfig.rb new file mode 100644 index 000000000..089d1c939 --- /dev/null +++ b/otelconfig/lib/opentelemetry_otelconfig.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'opentelemetry/otelconfig' diff --git a/otelconfig/lib/tasks/generate_constants.rake b/otelconfig/lib/tasks/generate_constants.rake new file mode 100644 index 000000000..13e560251 --- /dev/null +++ b/otelconfig/lib/tasks/generate_constants.rake @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +# +# rake generate:constants +# +# Downloads the upstream opentelemetry-configuration JSON Schema (YAML format) +# and regenerates lib/opentelemetry/constants/generated_constants.rb from it. +# +# Usage: +# bundle exec rake generate:constants +# bundle exec rake generate:constants SCHEMA_VERSION=v1.0.0-rc.3 +# bundle exec rake generate:constants SCHEMA_DIR=/path/to/local/schema + +require 'fileutils' +require 'open-uri' +require 'tmpdir' +require 'zlib' +require 'stringio' +require 'yaml' +require 'rubygems/package' + +SCHEMA_VERSION = ENV.fetch('SCHEMA_VERSION', 'v1.0.0-rc.3') +SCHEMA_TARBALL = "https://api.github.com/repos/open-telemetry/opentelemetry-configuration/tarball/#{SCHEMA_VERSION}".freeze +OUTPUT_FILE = File.expand_path('../opentelemetry/constants/generated_constants.rb', __dir__) + +# Ruby tarball downloader +module SchemaDownloader + # Downloads the gzip tarball from +url+ and extracts +schema/*.yaml+ files into +dest_dir+. + def self.fetch_schema(url, dest_dir) + FileUtils.mkdir_p(dest_dir) + + raw = URI.open(url, 'rb', 'User-Agent' => 'opentelemetry-otelconfig-generator').read # rubocop:disable Security/Open + gz = Zlib::GzipReader.new(StringIO.new(raw)) + + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + next unless entry.file? + + rel = entry.full_name.split('/', 2).last + next unless rel&.start_with?('schema/') + + File.binwrite(File.join(dest_dir, File.basename(rel)), entry.read) + end + end + + dest_dir + end +end + +# merges the split schema files into one flat $defs table and +# resolves the file-level $ref aliases +module SchemaReader + ROOT_FILE = 'opentelemetry_configuration.yaml' + + # Loads and returns the parsed YAML document at +path+. + def self.load_yaml(path) + YAML.safe_load(File.read(path)) + end + + # Returns a single Hash mapping every definition name to its schema body. + def self.build_defs(schema_dir) + file_roots = {} + defs = {} + + Dir.glob(File.join(schema_dir, '*.yaml')).each do |f| + next if File.basename(f).start_with?('meta_schema') + + doc = load_yaml(f) + next unless doc.is_a?(Hash) + + file_roots[File.basename(f)] = doc + (doc['$defs'] || {}).each { |name, body| defs[name] = body } + end + + root = file_roots.fetch(ROOT_FILE, {}) + + # Resolve `Name: { $ref: some_file.yaml }` aliases to the file's root schema. + (root['$defs'] || {}).each do |name, body| + next unless body.is_a?(Hash) && body['$ref'].is_a?(String) && body['$ref'].end_with?('.yaml') + + target = file_roots[body['$ref']] + defs[name] = target if target + end + + # The root configuration object itself. + defs['OpenTelemetryConfiguration'] = root.reject { |k, _| k == '$defs' } + defs + end +end + +# schema $defs → ruby struct definitions in constants/generated_constants.rb +module ConstantsGenerator + module_function + + # "detection/development" become "detection_development". + def field_name(key) + key.gsub(%r{[/.-]}, '_').gsub(/[^a-zA-Z0-9_]/, '_') + end + + # extracts the definition name from a $ref string + def ref_name(schema) + return nil unless schema.is_a?(Hash) + + ref = schema['$ref'] + return nil unless ref.is_a?(String) && ref.include?('#/$defs/') + + ref.split('#/$defs/').last + end + + # A definition becomes a generated Struct when it declares (non-empty) properties. + def struct?(defs, name) + body = defs[name] + body.is_a?(Hash) && body['properties'].is_a?(Hash) && !body['properties'].empty? + end + + # Returns true when +name+ is a presence-only marker object (no properties, no enum). + def marker?(defs, name) + body = defs[name] + return false unless body.is_a?(Hash) + return false if body['properties'] + return false if body['enum'] + + add = body['additionalProperties'] + object_type = body['type'] == 'object' || (body['type'].is_a?(Array) && body['type'].include?('object')) + object_type && (add == false || add.nil?) + end + + # Returns true when +prop+ describes an array-typed schema property. + def array_schema?(prop) + Array(prop['type']).include?('array') || prop.key?('items') + end + + # builds the value for one property given local var `h`. + def value_expr(prop, defs, key) + direct = ref_name(prop) + + if direct && struct?(defs, direct) + "#{direct}.from_hash(h['#{key}'])" + elsif direct && marker?(defs, direct) + "h.key?('#{key}')" + elsif array_schema?(prop) + item_ref = ref_name(prop['items'] || {}) + if item_ref && struct?(defs, item_ref) + "Array(h['#{key}']).filter_map { |e| #{item_ref}.from_hash(e) }" + else + "h['#{key}']" + end + else + "h['#{key}']" + end + end + + # Renders a Ruby Struct definition string for the given schema object +body+. + def render_struct(name, body, defs) + props = body['properties'] + member_names = props.keys.map { |k| field_name(k) } + + # capture them so non-schema plugins (e.g. additionalProperties) + capture_additional = body['additionalProperties'] != false + member_names << 'additional_properties' if capture_additional + + members = member_names.map { |m| ":#{m}" }.join(",\n ") + + assignments = props.map do |key, prop| + " #{field_name(key)}: #{value_expr(prop, defs, key)}" + end + if capture_additional + known = props.keys.map { |k| "'#{k}'" }.join(', ') + assignments << " additional_properties: h.reject { |k, _| [#{known}].include?(k) }" + end + + <<~RUBY + #{name} = Struct.new( + #{members}, + keyword_init: true + ) do + def self.from_hash(h) + return nil unless h.is_a?(Hash) + + new( + #{assignments.join(",\n")} + ) + end + end + RUBY + end + + # Generates the full Ruby source for all object struct definitions in +defs+. + def generate(defs) + names = defs.keys.select { |n| struct?(defs, n) }.sort + parts = [header(names.size)] + names.each { |name| parts << render_struct(name, defs[name], defs) } + parts.join("\n") + end + + # Returns the frozen-string-literal header comment for the generated file. + def header(count) + <<~RUBY + # frozen_string_literal: true + + # Copyright The OpenTelemetry Authors + # SPDX-License-Identifier: Apache-2.0 + # + # DO NOT EDIT — generated by `bundle exec rake generate:constants` + # Schema: open-telemetry/opentelemetry-configuration #{SCHEMA_VERSION} + # Structs: #{count} (one per object definition in the schema) + # To regenerate: bundle exec rake generate:constants + + RUBY + end +end + +namespace :generate do + desc "Download OTel configuration schema (#{SCHEMA_VERSION}) and regenerate lib/opentelemetry/constants/generated_constants.rb" + task :constants do + schema_dir = ENV['SCHEMA_DIR'] + + if schema_dir + puts "Using local schema directory: #{schema_dir}" + else + tmpdir = Dir.mktmpdir('otel-schema-') + schema_dir = File.join(tmpdir, 'schema') + + puts "Downloading schema #{SCHEMA_VERSION} from GitHub..." + begin + SchemaDownloader.fetch_schema(SCHEMA_TARBALL, schema_dir) + rescue StandardError => e + abort "Failed to download schema: #{e.class}: #{e.message}" + end + + puts "Schema extracted to #{schema_dir}" + end + + puts 'Loading and merging schema files...' + defs = SchemaReader.build_defs(schema_dir) + object_count = defs.keys.count { |n| ConstantsGenerator.struct?(defs, n) } + puts "Found #{defs.size} definitions (#{object_count} object structs)." + + puts 'Generating constants...' + output = ConstantsGenerator.generate(defs) + + FileUtils.mkdir_p(File.dirname(OUTPUT_FILE)) + File.write(OUTPUT_FILE, output) + puts "Written to #{OUTPUT_FILE}" + end +end diff --git a/otelconfig/opentelemetry-otelconfig.gemspec b/otelconfig/opentelemetry-otelconfig.gemspec new file mode 100644 index 000000000..7c2fe9b82 --- /dev/null +++ b/otelconfig/opentelemetry-otelconfig.gemspec @@ -0,0 +1,33 @@ +# 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/otelconfig/version' + +Gem::Specification.new do |spec| + spec.name = 'opentelemetry-otelconfig' + spec.version = OpenTelemetry::OtelConfig::VERSION + spec.authors = ['OpenTelemetry Authors'] + spec.email = ['cncf-opentelemetry-contributors@lists.cncf.io'] + + spec.summary = 'Declare Config implementation for OpenTelemetry' + spec.homepage = 'https://github.com/open-telemetry/opentelemetry-ruby' + spec.license = 'Apache-2.0' + + spec.files = ::Dir.glob('lib/**/*.rb') + + ::Dir.glob('*.md') + + ['LICENSE', '.yardopts'] + spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 3.3' + + 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/tree/#{spec.name}/v#{spec.version}/otelconfig" + spec.metadata['bug_tracker_uri'] = 'https://github.com/open-telemetry/opentelemetry-ruby/issues' + spec.metadata['documentation_uri'] = "https://rubydoc.info/gems/#{spec.name}/#{spec.version}" + end +end diff --git a/otelconfig/test/components/trace_test.rb b/otelconfig/test/components/trace_test.rb new file mode 100644 index 000000000..731c2d83a --- /dev/null +++ b/otelconfig/test/components/trace_test.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::OtelConfig do + describe 'tracer_provider' do + describe 'simple processor with console exporter' do + it 'installs a SimpleSpanProcessor backed by ConsoleSpanExporter' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + tp = OpenTelemetry.tracer_provider + + _(tp).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + + processors = tp.instance_variable_get(:@span_processors) + _(processors.size).must_equal 1 + + _(processors[0]).must_be_instance_of OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor + _(processors[0].instance_variable_get(:@span_exporter)).must_be_instance_of OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter + end + end + end + + describe 'batch processor with OTLP HTTP exporter' do + it 'installs a BatchSpanProcessor with the correct endpoint, headers, compression, and timeout' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - batch: + schedule_delay: 5000 + export_timeout: 30000 + max_queue_size: 2048 + max_export_batch_size: 512 + exporter: + otlp_http: + endpoint: http://localhost:4318/v1/traces + headers: + - name: api-key + value: "secret-token" + compression: gzip + timeout: 10000 + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + processors = OpenTelemetry.tracer_provider.instance_variable_get(:@span_processors) + _(processors.size).must_equal 1 + + bsp = processors[0] + _(bsp).must_be_instance_of OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor + + exporter = bsp.instance_variable_get(:@exporter) + _(exporter).must_be_instance_of OpenTelemetry::Exporter::OTLP::Exporter + + _(exporter.instance_variable_get(:@uri).to_s).must_equal 'http://localhost:4318/v1/traces' + _(exporter.instance_variable_get(:@compression)).must_equal 'gzip' + _(exporter.instance_variable_get(:@timeout)).must_equal 10.0 + _(exporter.instance_variable_get(:@headers)['api-key']).must_equal 'secret-token' + end + end + + it 'forwards batch tuning parameters to the processor' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - batch: + schedule_delay: 3000 + export_timeout: 15000 + max_queue_size: 1024 + max_export_batch_size: 256 + exporter: + otlp_http: + endpoint: http://localhost:4318/v1/traces + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + processors = OpenTelemetry.tracer_provider.instance_variable_get(:@span_processors) + _(processors.size).must_equal 1 + + bsp = processors[0] + _(bsp.instance_variable_get(:@delay_seconds) * 1000).must_equal 3000.0 + _(bsp.instance_variable_get(:@max_queue_size)).must_equal 1024 + _(bsp.instance_variable_get(:@batch_size)).must_equal 256 + end + end + end + + describe 'multiple processors' do + it 'adds processors in declaration order: batch OTLP first, simple console second' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - batch: + exporter: + otlp_http: + endpoint: http://localhost:4318/v1/traces + - simple: + exporter: + console: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + processors = OpenTelemetry.tracer_provider.instance_variable_get(:@span_processors) + _(processors.size).must_equal 2 + + _(processors[0]).must_be_instance_of OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor + _(processors[0].instance_variable_get(:@exporter)).must_be_instance_of OpenTelemetry::Exporter::OTLP::Exporter + + _(processors[1]).must_be_instance_of OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor + _(processors[1].instance_variable_get(:@span_exporter)).must_be_instance_of OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter + end + end + end + + describe 'sampler configuration' do + it 'uses ALWAYS_ON when sampler is always_on' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - simple: + exporter: + console: + sampler: + always_on: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider.sampler).must_equal OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON + end + end + + it 'uses ALWAYS_OFF when sampler is always_off' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - simple: + exporter: + console: + sampler: + always_off: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider.sampler).must_equal OpenTelemetry::SDK::Trace::Samplers::ALWAYS_OFF + end + end + + it 'uses TraceIdRatioBased with the configured ratio' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - simple: + exporter: + console: + sampler: + trace_id_ratio_based: + ratio: 0.25 + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + sampler = OpenTelemetry.tracer_provider.sampler + + _(sampler.description).must_match(/0.25/) + end + end + + it 'wraps the root sampler in ParentBased' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - simple: + exporter: + console: + sampler: + parent_based: + root: + always_on: + remote_parent_sampled: + always_on: + remote_parent_not_sampled: + always_off: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + sampler = OpenTelemetry.tracer_provider.sampler + + _(sampler.description).must_match(/ParentBased/) + end + end + end + + describe 'span limits' do + it 'applies all configured limits to the TracerProvider' do + with_config(<<~YAML) do |path| + file_format: "1.0" + tracer_provider: + processors: + - simple: + exporter: + console: + limits: + attribute_value_length_limit: 512 + attribute_count_limit: 64 + event_count_limit: 32 + link_count_limit: 16 + event_attribute_count_limit: 8 + link_attribute_count_limit: 4 + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + limits = OpenTelemetry.tracer_provider + .instance_variable_get(:@span_limits) + + _(limits.attribute_length_limit).must_equal 512 + _(limits.attribute_count_limit).must_equal 64 + _(limits.event_count_limit).must_equal 32 + _(limits.link_count_limit).must_equal 16 + _(limits.event_attribute_count_limit).must_equal 8 + _(limits.link_attribute_count_limit).must_equal 4 + end + end + end + end +end diff --git a/otelconfig/test/opentelemetry/otelconfig/instrumentation_test.rb b/otelconfig/test/opentelemetry/otelconfig/instrumentation_test.rb new file mode 100644 index 000000000..bfac9f623 --- /dev/null +++ b/otelconfig/test/opentelemetry/otelconfig/instrumentation_test.rb @@ -0,0 +1,500 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::OtelConfig do + # Temporarily replaces build_instrumentation_name_map with a stub that + # returns +map+, then restores the original after the block. + def with_name_map(map) + original = OpenTelemetry::OtelConfig.method(:build_instrumentation_name_map) + OpenTelemetry::OtelConfig.singleton_class.undef_method(:build_instrumentation_name_map) + OpenTelemetry::OtelConfig.define_singleton_method(:build_instrumentation_name_map) { map } + yield + ensure + OpenTelemetry::OtelConfig.singleton_class.undef_method(:build_instrumentation_name_map) + OpenTelemetry::OtelConfig.define_singleton_method(:build_instrumentation_name_map, original) + end + + # Temporarily replaces OpenTelemetry::Instrumentation.registry with a fake + # registry containing +classes+, then restores the original method. + def with_registry_classes(classes) + fake_registry = Object.new + fake_registry.instance_variable_set(:@instrumentation, classes) + + original = OpenTelemetry::Instrumentation.method(:registry) + OpenTelemetry::Instrumentation.singleton_class.undef_method(:registry) + OpenTelemetry::Instrumentation.define_singleton_method(:registry) { fake_registry } + yield + ensure + OpenTelemetry::Instrumentation.singleton_class.undef_method(:registry) + OpenTelemetry::Instrumentation.define_singleton_method(:registry, original) + end + + # Builds a fake instrumentation class that responds to .instance and returns + # an object exposing a #name string. + def fake_instrumentation_class(full_name) + instance = Struct.new(:name).new(full_name) + Class.new.tap do |klass| + klass.define_singleton_method(:instance) { instance } + end + end + + # Fake name map used throughout the stubbed unit tests. + FAKE_NAME_MAP = { + 'net_http' => 'OpenTelemetry::Instrumentation::Net::HTTP', + 'rack' => 'OpenTelemetry::Instrumentation::Rack', + 'redis' => 'OpenTelemetry::Instrumentation::Redis', + 'sidekiq' => 'OpenTelemetry::Instrumentation::Sidekiq', + 'active_job' => 'OpenTelemetry::Instrumentation::ActiveJob', + 'faraday' => 'OpenTelemetry::Instrumentation::Faraday', + 'mysql2' => 'OpenTelemetry::Instrumentation::Mysql2', + 'pg' => 'OpenTelemetry::Instrumentation::PG', + 'grpc' => 'OpenTelemetry::Instrumentation::GRPC', + 'graphql' => 'OpenTelemetry::Instrumentation::GraphQL', + 'dalli' => 'OpenTelemetry::Instrumentation::Dalli', + 'action_pack' => 'OpenTelemetry::Instrumentation::ActionPack' + }.freeze + + describe 'instrumentation' do + describe 'build_instrumentation_name_map' do + it 'returns a Hash for the current registry' do + result = OpenTelemetry::OtelConfig.build_instrumentation_name_map + + _(result).must_be_kind_of Hash + end + + it 'maps full instrumentation names to snake_case short names' do + classes = [ + fake_instrumentation_class('OpenTelemetry::Instrumentation::Net::HTTP'), + fake_instrumentation_class('OpenTelemetry::Instrumentation::ActionPack'), + fake_instrumentation_class('OpenTelemetry::Instrumentation::Redis') + ] + + with_registry_classes(classes) do + result = OpenTelemetry::OtelConfig.build_instrumentation_name_map + + _(result).must_equal( + 'net_http' => 'OpenTelemetry::Instrumentation::Net::HTTP', + 'action_pack' => 'OpenTelemetry::Instrumentation::ActionPack', + 'redis' => 'OpenTelemetry::Instrumentation::Redis' + ) + end + end + + it 'joins nested module segments with underscores' do + classes = [ + fake_instrumentation_class('OpenTelemetry::Instrumentation::Foo::Bar::HTTP') + ] + + with_registry_classes(classes) do + result = OpenTelemetry::OtelConfig.build_instrumentation_name_map + + _(result['foo_bar_http']).must_equal 'OpenTelemetry::Instrumentation::Foo::Bar::HTTP' + end + end + + it 'returns {} when registry instrumentation data is invalid' do + with_registry_classes(nil) do + result = OpenTelemetry::OtelConfig.build_instrumentation_name_map + + _(result).must_equal({}) + end + end + end + + describe 'configure_from_file with instrumentation section' do + it 'does not raise when the instrumentation section is absent' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + + it 'does not raise when instrumentation gems are not installed' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + instrumentation/development: + ruby: + net_http: + untraced_hosts: + - example.com + rack: + record_frontend_span: false + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + + it 'does not raise for multiple instrumentations with mixed option types' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + instrumentation/development: + ruby: + redis: + peer_service: "cache-cluster" + trace_root_spans: true + db_statement: obfuscate + sidekiq: + span_naming: queue + propagation_style: link + trace_launcher_heartbeat: false + active_job: + propagation_style: child + force_flush: true + span_naming: job_class + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + end + + # build_instrumentation_config_map — nil / invalid inputs + describe 'build_instrumentation_config_map with invalid inputs' do + it 'returns {} when config is nil' do + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map(nil)).must_equal({}) + end + + it 'returns {} when config is not a Hash' do + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map('string')).must_equal({}) + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map(42)).must_equal({}) + end + + it 'returns {} when the ruby key is absent' do + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map({ 'other' => {} })).must_equal({}) + end + + it 'returns {} when ruby is not a Hash' do + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map({ 'ruby' => 'flat' })).must_equal({}) + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map({ 'ruby' => [] })).must_equal({}) + end + + it 'returns {} when ruby is an empty Hash' do + _(OpenTelemetry::OtelConfig.build_instrumentation_config_map({ 'ruby' => {} })).must_equal({}) + end + end + + # build_instrumentation_config_map — transformation logic (stubbed name map) + describe 'build_instrumentation_config_map with stubbed name map' do + describe 'core transformation behaviour' do + it 'maps the short name to the full class name' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'net_http' => {} } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result.keys).must_include 'OpenTelemetry::Instrumentation::Net::HTTP' + end + end + + it 'symbolizes option keys' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'net_http' => { 'untraced_hosts' => ['localhost'] } } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Net::HTTP'] + _(opts.keys).must_include :untraced_hosts + _(opts.keys).wont_include 'untraced_hosts' + end + end + + it 'treats nil options as an empty Hash' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'net_http' => nil } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result['OpenTelemetry::Instrumentation::Net::HTTP']).must_equal({}) + end + end + + it 'treats non-Hash options as an empty Hash' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'net_http' => 'enabled' } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result['OpenTelemetry::Instrumentation::Net::HTTP']).must_equal({}) + end + end + + it 'skips and does not include unknown short names' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'totally_unknown_lib' => { 'opt' => 1 } } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result).must_equal({}) + end + end + + it 'maps multiple instrumentations in one call' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'net_http' => { 'untraced_hosts' => ['internal.example.com'] }, + 'redis' => { 'peer_service' => 'cache', 'trace_root_spans' => true } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result.size).must_equal 2 + _(result['OpenTelemetry::Instrumentation::Net::HTTP']).must_equal(untraced_hosts: ['internal.example.com']) + _(result['OpenTelemetry::Instrumentation::Redis']).must_equal(peer_service: 'cache', trace_root_spans: true) + end + end + end + + # Representative option shapes from each instrumentation + describe 'net_http options' do + it 'maps untraced_hosts array' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'net_http' => { 'untraced_hosts' => ['metrics.example.com', 'localhost'] } } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result['OpenTelemetry::Instrumentation::Net::HTTP']).must_equal( + untraced_hosts: ['metrics.example.com', 'localhost'] + ) + end + end + end + + describe 'rack options' do + it 'maps all rack options correctly' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'rack' => { + 'allowed_request_headers' => %w[X-Request-ID X-Forwarded-For], + 'allowed_response_headers' => ['X-Response-Time'], + 'untraced_endpoints' => ['/healthz', '/metrics'], + 'record_frontend_span' => true, + 'use_rack_events' => false + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Rack'] + _(opts[:allowed_request_headers]).must_equal %w[X-Request-ID X-Forwarded-For] + _(opts[:allowed_response_headers]).must_equal ['X-Response-Time'] + _(opts[:untraced_endpoints]).must_equal ['/healthz', '/metrics'] + _(opts[:record_frontend_span]).must_equal true + _(opts[:use_rack_events]).must_equal false + end + end + end + + describe 'redis options' do + it 'maps peer_service, trace_root_spans, and db_statement' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'redis' => { + 'peer_service' => 'redis-primary', + 'trace_root_spans' => false, + 'db_statement' => 'obfuscate' + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Redis'] + _(opts[:peer_service]).must_equal 'redis-primary' + _(opts[:trace_root_spans]).must_equal false + _(opts[:db_statement]).must_equal 'obfuscate' + end + end + end + + describe 'sidekiq options' do + it 'maps span_naming, propagation_style, and boolean trace flags' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'sidekiq' => { + 'span_naming' => 'job_class', + 'propagation_style' => 'child', + 'trace_launcher_heartbeat' => true, + 'trace_poller_enqueue' => false, + 'trace_poller_wait' => false, + 'trace_processor_process_one' => true, + 'peer_service' => 'sidekiq-workers' + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Sidekiq'] + _(opts[:span_naming]).must_equal 'job_class' + _(opts[:propagation_style]).must_equal 'child' + _(opts[:trace_launcher_heartbeat]).must_equal true + _(opts[:trace_poller_enqueue]).must_equal false + _(opts[:trace_processor_process_one]).must_equal true + _(opts[:peer_service]).must_equal 'sidekiq-workers' + end + end + end + + describe 'active_job options' do + it 'maps propagation_style, force_flush, and span_naming' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'active_job' => { + 'propagation_style' => 'none', + 'force_flush' => true, + 'span_naming' => 'job_class' + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::ActiveJob'] + _(opts[:propagation_style]).must_equal 'none' + _(opts[:force_flush]).must_equal true + _(opts[:span_naming]).must_equal 'job_class' + end + end + end + + describe 'faraday options' do + it 'maps span_kind, peer_service, and enable_internal_instrumentation' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'faraday' => { + 'span_kind' => 'internal', + 'peer_service' => 'downstream-api', + 'enable_internal_instrumentation' => true + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Faraday'] + _(opts[:span_kind]).must_equal 'internal' + _(opts[:peer_service]).must_equal 'downstream-api' + _(opts[:enable_internal_instrumentation]).must_equal true + end + end + end + + describe 'mysql2 options' do + it 'maps db_statement, obfuscation_limit, span_name, and peer_service' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'mysql2' => { + 'peer_service' => 'mysql-primary', + 'db_statement' => 'omit', + 'span_name' => 'db_name', + 'obfuscation_limit' => 500 + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Mysql2'] + _(opts[:peer_service]).must_equal 'mysql-primary' + _(opts[:db_statement]).must_equal 'omit' + _(opts[:span_name]).must_equal 'db_name' + _(opts[:obfuscation_limit]).must_equal 500 + end + end + end + + describe 'pg options' do + it 'maps db_statement, obfuscation_limit, and peer_service' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'pg' => { + 'peer_service' => 'postgres-replica', + 'db_statement' => 'include', + 'obfuscation_limit' => 1000 + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::PG'] + _(opts[:peer_service]).must_equal 'postgres-replica' + _(opts[:db_statement]).must_equal 'include' + _(opts[:obfuscation_limit]).must_equal 1000 + end + end + end + + describe 'grpc options' do + it 'maps allowed_metadata_headers and peer_service' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'grpc' => { + 'allowed_metadata_headers' => %w[x-correlation-id x-tenant-id], + 'peer_service' => 'grpc-backend' + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::GRPC'] + _(opts[:allowed_metadata_headers]).must_equal %w[x-correlation-id x-tenant-id] + _(opts[:peer_service]).must_equal 'grpc-backend' + end + end + end + + describe 'graphql options' do + it 'maps schemas array and all boolean platform flags' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'graphql' => { + 'schemas' => [], + 'enable_platform_field' => true, + 'enable_platform_authorized' => false, + 'enable_platform_resolve_type' => true, + 'legacy_platform_span_names' => false, + 'legacy_tracing' => false + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::GraphQL'] + _(opts[:schemas]).must_equal [] + _(opts[:enable_platform_field]).must_equal true + _(opts[:enable_platform_authorized]).must_equal false + _(opts[:enable_platform_resolve_type]).must_equal true + end + end + end + + describe 'dalli options' do + it 'maps peer_service and db_statement' do + with_name_map(FAKE_NAME_MAP) do + cfg = { + 'ruby' => { + 'dalli' => { + 'peer_service' => 'memcached', + 'db_statement' => 'omit' + } + } + } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + opts = result['OpenTelemetry::Instrumentation::Dalli'] + _(opts[:peer_service]).must_equal 'memcached' + _(opts[:db_statement]).must_equal 'omit' + end + end + end + + describe 'action_pack options' do + it 'maps span_naming' do + with_name_map(FAKE_NAME_MAP) do + cfg = { 'ruby' => { 'action_pack' => { 'span_naming' => 'class' } } } + result = OpenTelemetry::OtelConfig.build_instrumentation_config_map(cfg) + _(result['OpenTelemetry::Instrumentation::ActionPack']).must_equal(span_naming: 'class') + end + end + end + end + end +end diff --git a/otelconfig/test/opentelemetry/otelconfig/propagation_test.rb b/otelconfig/test/opentelemetry/otelconfig/propagation_test.rb new file mode 100644 index 000000000..1c1fa8936 --- /dev/null +++ b/otelconfig/test/opentelemetry/otelconfig/propagation_test.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::OtelConfig do + describe 'propagator' do + describe 'composite array' do + it 'configures a single tracecontext propagator — correct instance and fields' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - tracecontext: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagation.fields).must_include 'traceparent' + _(propagation.fields).must_include 'tracestate' + end + end + + it 'configures a single baggage propagator — correct instance and fields' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - baggage: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Baggage::Propagation::TextMapPropagator + _(propagation.fields).must_include 'baggage' + end + end + + it 'composes tracecontext and baggage — correct instance, propagator order, and fields' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - tracecontext: + - baggage: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Context::Propagation::CompositeTextMapPropagator + + propagators = propagation.instance_variable_get(:@propagators) + _(propagators.size).must_equal 2 + _(propagators[0]).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagators[1]).must_be_instance_of OpenTelemetry::Baggage::Propagation::TextMapPropagator + + fields = propagation.fields + _(fields).must_include 'traceparent' + _(fields).must_include 'tracestate' + _(fields).must_include 'baggage' + _(fields.index('traceparent')).must_be :<, fields.index('baggage') + end + end + + it 'silently skips unknown names and still applies the valid propagators' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - tracecontext: + - nonexistent_xyz: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagation.fields).must_include 'traceparent' + end + end + + it 'leaves propagation unconfigured when the composite array is empty' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: [] + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + fields = OpenTelemetry.propagation.fields + _(fields).wont_include 'traceparent' + _(fields).wont_include 'baggage' + end + end + end + + describe 'composite_list string' do + it 'configures tracecontext and baggage from a comma-separated list' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite_list: "tracecontext,baggage" + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Context::Propagation::CompositeTextMapPropagator + + propagators = propagation.instance_variable_get(:@propagators) + _(propagators[0]).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagators[1]).must_be_instance_of OpenTelemetry::Baggage::Propagation::TextMapPropagator + + _(propagation.fields).must_include 'traceparent' + _(propagation.fields).must_include 'baggage' + end + end + + it 'strips whitespace from each entry' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite_list: " tracecontext , baggage " + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + fields = OpenTelemetry.propagation.fields + _(fields).must_include 'traceparent' + _(fields).must_include 'baggage' + end + end + + it 'silently skips unknown entries and still applies the valid propagators' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite_list: "tracecontext,totally_unknown_propagator" + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagation.fields).must_include 'traceparent' + end + end + end + + describe 'composite and composite_list merging' do + it 'merges both sources and deduplicates — composite names come first' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - tracecontext: + composite_list: "baggage,tracecontext" + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of OpenTelemetry::Context::Propagation::CompositeTextMapPropagator + + propagators = propagation.instance_variable_get(:@propagators) + # tracecontext first (from composite), baggage second (from composite_list), no duplicate tracecontext + _(propagators.size).must_equal 2 + _(propagators[0]).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + _(propagators[1]).must_be_instance_of OpenTelemetry::Baggage::Propagation::TextMapPropagator + + _(propagation.fields).must_include 'traceparent' + _(propagation.fields).must_include 'baggage' + end + end + end + + describe 'optional gem propagators' do + %w[b3 b3multi jaeger ottrace google_cloud_trace_context].each do |name| + it "does not raise and keeps tracecontext when #{name} is requested" do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - #{name}: + - tracecontext: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + _(OpenTelemetry.propagation.fields).must_include 'traceparent' + end + end + end + + # xray does not implement a `fields` instance method, so the composite's + # fields aggregation cannot be used. Instead we verify the propagator is + # present in the composite's @propagators array and is the correct type. + describe 'xray (gem required)' do + before { require 'opentelemetry-propagator-xray' } + + it 'configures xray alone — propagation is an XRay::TextMapPropagator instance' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - xray: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + _(OpenTelemetry.propagation).must_be_instance_of \ + OpenTelemetry::Propagator::XRay::TextMapPropagator + end + end + + it 'composes xray with tracecontext — both are present in @propagators in order' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + propagator: + composite: + - xray: + - tracecontext: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + propagation = OpenTelemetry.propagation + _(propagation).must_be_instance_of \ + OpenTelemetry::Context::Propagation::CompositeTextMapPropagator + + propagators = propagation.instance_variable_get(:@propagators) + _(propagators.size).must_equal 2 + _(propagators[0]).must_be_instance_of OpenTelemetry::Propagator::XRay::TextMapPropagator + _(propagators[1]).must_be_instance_of OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator + end + end + end + end + + describe 'when propagator section is absent' do + it 'leaves propagation unconfigured' do + with_config(<<~YAML) do |path| + file_format: "1.0" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator if sdk.propagator + + fields = OpenTelemetry.propagation.fields + _(fields).wont_include 'traceparent' + _(fields).wont_include 'baggage' + end + end + end + end +end diff --git a/otelconfig/test/opentelemetry/otelconfig/resource_test.rb b/otelconfig/test/opentelemetry/otelconfig/resource_test.rb new file mode 100644 index 000000000..687b20da4 --- /dev/null +++ b/otelconfig/test/opentelemetry/otelconfig/resource_test.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::OtelConfig do + describe 'resource attributes' do + describe 'attributes array with no type field' do + it 'stores a plain string value' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "my-service" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'my-service' + end + end + + it 'stores a YAML-parsed integer as-is' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: instance.count + value: 3 + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['instance.count']).must_equal 3 + end + end + + it 'stores a YAML-parsed boolean as-is' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: feature.enabled + value: true + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['feature.enabled']).must_equal true + end + end + end + + describe 'attributes array with type: string' do + it 'converts an integer value to its string representation' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: build.number + value: 42 + type: string + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['build.number']).must_equal '42' + _(attrs['build.number']).must_be_kind_of String + end + end + + it 'keeps an existing string value as a string' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.namespace + value: "payments" + type: string + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.namespace']).must_equal 'payments' + end + end + end + + describe 'attributes array with typed fields' do + it 'coerces each supported type correctly' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: debug.enabled + value: true + type: bool + - name: debug.disabled + value: false + type: bool + - name: flag.true + value: "true" + type: bool + - name: flag.false + value: "false" + type: bool + - name: max.retries + value: 5 + type: int + - name: sample.ratio + value: 0.25 + type: double + - name: host.tags + value: [web, api, gateway] + type: string_array + - name: feature.flags + value: [true, false, true] + type: bool_array + - name: allowed.ports + value: [8080, 8443, 9000] + type: int_array + - name: cpu.percentages + value: [0.25, 0.50, 0.75] + type: double_array + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + + # bool: native YAML booleans + _(attrs['debug.enabled']).must_equal true + _(attrs['debug.disabled']).must_equal false + + # bool: string coercion + _(attrs['flag.true']).must_equal true + _(attrs['flag.false']).must_equal false + + # int + _(attrs['max.retries']).must_equal 5 + _(attrs['max.retries']).must_be_kind_of Integer + + # double + _(attrs['sample.ratio']).must_equal 0.25 + _(attrs['sample.ratio']).must_be_kind_of Float + + # string_array + _(attrs['host.tags']).must_equal %w[web api gateway] + + # bool_array + _(attrs['feature.flags']).must_equal [true, false, true] + + # int_array + _(attrs['allowed.ports']).must_equal [8080, 8443, 9000] + + # double_array + _(attrs['cpu.percentages']).must_equal [0.25, 0.50, 0.75] + end + end + end + + describe 'attributes array with invalid entries' do + it 'skips entries that have no name key' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - value: "orphan" + - name: service.name + value: "valid" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'valid' + _(attrs.value?('orphan')).must_equal false + end + end + + it 'skips entries whose value is null (YAML ~)' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: empty.key + value: ~ + - name: service.name + value: "present" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs.key?('empty.key')).must_equal false + _(attrs['service.name']).must_equal 'present' + end + end + end + + describe 'multiple attributes together' do + it 'includes all named attributes regardless of type' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "inventory-api" + - name: deployment.environment + value: "production" + - name: service.version + value: "2.1.0" + - name: max.connections + value: 100 + type: int + - name: sample.ratio + value: 0.5 + type: double + - name: debug.enabled + value: false + type: bool + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'inventory-api' + _(attrs['deployment.environment']).must_equal 'production' + _(attrs['service.version']).must_equal '2.1.0' + _(attrs['max.connections']).must_equal 100 + _(attrs['sample.ratio']).must_equal 0.5 + _(attrs['debug.enabled']).must_equal false + end + end + end + + describe 'attributes_list' do + it 'parses a single key=value pair' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes_list: "env=production" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['env']).must_equal 'production' + end + end + + it 'parses multiple comma-separated key=value pairs' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes_list: "region=us-east-1,team=platform,tier=backend" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['region']).must_equal 'us-east-1' + _(attrs['team']).must_equal 'platform' + _(attrs['tier']).must_equal 'backend' + end + end + + it 'preserves a value containing = by splitting only on the first =' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes_list: "auth.token=abc=def=ghi" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['auth.token']).must_equal 'abc=def=ghi' + end + end + end + + describe 'priority: attributes array over attributes_list' do + it 'keeps the attributes-array value when both sources define the same key' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "from-array" + attributes_list: "service.name=from-list" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'from-array' + end + end + + it 'still includes keys that appear only in attributes_list' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "from-array" + attributes_list: "service.name=from-list,extra.key=bonus-value" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'from-array' + _(attrs['extra.key']).must_equal 'bonus-value' + end + end + + it 'still includes keys that appear only in the attributes array' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: only-in-array + value: "here" + attributes_list: "only-in-list=there" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['only-in-array']).must_equal 'here' + _(attrs['only-in-list']).must_equal 'there' + end + end + end + + describe 'schema_url' do + it 'does not raise and still applies all attributes' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + schema_url: "https://opentelemetry.io/schemas/1.21.0" + attributes: + - name: service.name + value: "schema-test" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'schema-test' + end + end + end + + describe 'detection/development' do + it 'does not raise for an unknown detector and preserves explicit attributes' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + detection/development: + detectors: + - unknown_detector_xyz: + attributes: + - name: service.name + value: "detection-test" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'detection-test' + end + end + + it 'applies included pattern filtering without raising' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + detection/development: + detectors: + - unknown_detector_xyz: + attributes: + included: + - "process.*" + excluded: [] + attributes: + - name: service.name + value: "filter-test" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'filter-test' + end + end + + it 'applies excluded pattern filtering without raising' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + detection/development: + detectors: + - unknown_detector_xyz: + attributes: + included: [] + excluded: + - "host.*" + attributes: + - name: service.name + value: "exclude-test" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'exclude-test' + end + end + end + + describe 'merged with default SDK resource' do + it 'preserves built-in telemetry.sdk.* attributes alongside custom ones' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "sdk-merge-test" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(attrs['service.name']).must_equal 'sdk-merge-test' + _(attrs).must_include 'telemetry.sdk.name' + _(attrs).must_include 'telemetry.sdk.language' + end + end + end + + describe 'resource shared across all providers' do + it 'applies the same resource attributes to the tracer_provider' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "shared-service" + - name: deployment.environment + value: "staging" + #{TRACER_PROVIDER_YAML} + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + tp_attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + + _(tp_attrs['service.name']).must_equal 'shared-service' + _(tp_attrs['deployment.environment']).must_equal 'staging' + end + end + end + end +end diff --git a/otelconfig/test/opentelemetry/otelconfig_test.rb b/otelconfig/test/opentelemetry/otelconfig_test.rb new file mode 100644 index 000000000..4b71a12c8 --- /dev/null +++ b/otelconfig/test/opentelemetry/otelconfig_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'test_helper' + +describe OpenTelemetry::OtelConfig do + describe 'disabled flag' do + it 'skips SDK provider setup when disabled: true' do + with_config(<<~YAML) do |path| + file_format: "1.0" + disabled: true + tracer_provider: + processors: + - simple: + exporter: + console: + YAML + OpenTelemetry::OtelConfig.configure_from_file(path) + + _(OpenTelemetry.tracer_provider).wont_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + + it 'applies SDK provider setup when disabled: false' do + with_config(<<~YAML) do |path| + file_format: "1.0" + disabled: false + tracer_provider: + processors: + - simple: + exporter: + console: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + + _(OpenTelemetry.tracer_provider).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + end + + describe 'when provider sections are absent' do + it 'does not install a tracer provider' do + with_config(<<~YAML) do |path| + file_format: "1.0" + YAML + OpenTelemetry::OtelConfig.configure_from_file(path) + + _(OpenTelemetry.tracer_provider).wont_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + end + end + end + + describe 'tracer_provider and propagator configured together' do + it 'creates the SDK tracer_provider with the shared resource and correct processors' do + with_config(<<~YAML) do |path| + file_format: "1.0" + resource: + attributes: + - name: service.name + value: "full-stack-test" + tracer_provider: + processors: + - simple: + exporter: + console: + propagator: + composite: + - tracecontext: + - baggage: + YAML + sdk = OpenTelemetry::OtelConfig.configure_from_file(path) + OpenTelemetry.tracer_provider = sdk.tracer_provider + OpenTelemetry.propagation = sdk.propagator + + _(OpenTelemetry.tracer_provider).must_be_instance_of OpenTelemetry::SDK::Trace::TracerProvider + + tp_attrs = OpenTelemetry.tracer_provider + .instance_variable_get(:@resource) + .attribute_enumerator.to_h + _(tp_attrs['service.name']).must_equal 'full-stack-test' + + fields = OpenTelemetry.propagation.fields + _(fields).must_include 'traceparent' + _(fields).must_include 'baggage' + end + end + end +end diff --git a/otelconfig/test/test_helper.rb b/otelconfig/test/test_helper.rb new file mode 100644 index 000000000..95804fa9e --- /dev/null +++ b/otelconfig/test/test_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +require 'simplecov' +SimpleCov.start + +require 'bundler/setup' + +$LOAD_PATH.unshift File.join(__dir__, '..', 'lib') + +require 'opentelemetry-sdk' +require 'opentelemetry-exporter-otlp' +require 'opentelemetry/otelconfig' +require 'opentelemetry/test_helpers' + +require 'minitest/autorun' +require 'minitest/spec' +require 'tempfile' + +# Writes +yaml+ to a Tempfile and yields its path, then deletes the file. +def with_config(yaml) + tmp = Tempfile.new(['otel-config', '.yaml']) + tmp.write(yaml) + tmp.close + yield tmp.path +ensure + tmp.unlink +end + +# Reset after every test across all spec files. +Minitest::Spec.after do + tp = OpenTelemetry.tracer_provider + tp.shutdown if tp.respond_to?(:shutdown) + + OpenTelemetry::TestHelpers.reset_opentelemetry +end + +# Shared minimal tracer_provider YAML used by end-to-end tests. +TRACER_PROVIDER_YAML = <<~PROVIDER + tracer_provider: + processors: + - simple: + exporter: + console: +PROVIDER