From 93ca5a38236a2c7d3400fcfbfb4137993f608ea9 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 09:25:12 -0800 Subject: [PATCH 1/7] feat: add Ruby observability plugin --- .../observability-ruby/.gitignore | 10 + .../observability-ruby/CHANGELOG.md | 17 + sdk/@launchdarkly/observability-ruby/Gemfile | 18 + .../observability-ruby/LICENSE.txt | 190 ++++++++ .../observability-ruby/README.md | 412 ++++++++++++++++++ sdk/@launchdarkly/observability-ruby/Rakefile | 16 + .../launchdarkly-observability.gemspec | 45 ++ .../lib/launchdarkly_observability.rb | 69 +++ .../lib/launchdarkly_observability/hook.rb | 207 +++++++++ .../opentelemetry_config.rb | 271 ++++++++++++ .../lib/launchdarkly_observability/plugin.rb | 126 ++++++ .../lib/launchdarkly_observability/rails.rb | 236 ++++++++++ .../lib/launchdarkly_observability/version.rb | 5 + .../observability-ruby/test/hook_test.rb | 180 ++++++++ .../test/integration_test.rb | 248 +++++++++++ .../test/middleware_test.rb | 130 ++++++ .../test/opentelemetry_config_test.rb | 193 ++++++++ .../observability-ruby/test/plugin_test.rb | 112 +++++ .../observability-ruby/test/test_helper.rb | 54 +++ 19 files changed, 2539 insertions(+) create mode 100644 sdk/@launchdarkly/observability-ruby/.gitignore create mode 100644 sdk/@launchdarkly/observability-ruby/CHANGELOG.md create mode 100644 sdk/@launchdarkly/observability-ruby/Gemfile create mode 100644 sdk/@launchdarkly/observability-ruby/LICENSE.txt create mode 100644 sdk/@launchdarkly/observability-ruby/README.md create mode 100644 sdk/@launchdarkly/observability-ruby/Rakefile create mode 100644 sdk/@launchdarkly/observability-ruby/launchdarkly-observability.gemspec create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/rails.rb create mode 100644 sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/version.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/hook_test.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/integration_test.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/middleware_test.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/opentelemetry_config_test.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/plugin_test.rb create mode 100644 sdk/@launchdarkly/observability-ruby/test/test_helper.rb diff --git a/sdk/@launchdarkly/observability-ruby/.gitignore b/sdk/@launchdarkly/observability-ruby/.gitignore new file mode 100644 index 000000000..c4368cade --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +*.gem +Gemfile.lock diff --git a/sdk/@launchdarkly/observability-ruby/CHANGELOG.md b/sdk/@launchdarkly/observability-ruby/CHANGELOG.md new file mode 100644 index 000000000..41ce9f7f3 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2024-02-03 + +### Added + +- Initial release of LaunchDarkly Observability Plugin for Ruby SDK +- OpenTelemetry-based tracing for flag evaluations +- Rails integration with Railtie and Rack middleware +- Support for traces, logs, and metrics export via OTLP +- Auto-instrumentation for Rails, ActiveRecord, Net::HTTP, and more +- Context propagation between HTTP requests and flag evaluations diff --git a/sdk/@launchdarkly/observability-ruby/Gemfile b/sdk/@launchdarkly/observability-ruby/Gemfile new file mode 100644 index 000000000..51480a5ec --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/Gemfile @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in launchdarkly-observability.gemspec +gemspec + +group :development, :test do + gem 'minitest', '~> 5.0' + gem 'rake', '~> 13.0' + gem 'rubocop', '~> 1.0' +end + +# Optional: Rails integration testing +group :test do + gem 'rails', '>= 6.0' + gem 'webmock', '~> 3.0' +end diff --git a/sdk/@launchdarkly/observability-ruby/LICENSE.txt b/sdk/@launchdarkly/observability-ruby/LICENSE.txt new file mode 100644 index 000000000..2dc1073b1 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/LICENSE.txt @@ -0,0 +1,190 @@ + 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 the 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 + + Copyright 2024 LaunchDarkly + + 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/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md new file mode 100644 index 000000000..6e89857e7 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -0,0 +1,412 @@ +# LaunchDarkly Observability Plugin for Ruby + +OpenTelemetry-based observability instrumentation for the LaunchDarkly Ruby SDK with full Rails support. + +## Overview + +This plugin automatically instruments LaunchDarkly feature flag evaluations with OpenTelemetry traces, providing visibility into: + +- Flag evaluation timing and results +- Evaluation reasons and rule matches +- Context information (user/organization) +- Error tracking for failed evaluations +- Correlation with HTTP requests in Rails applications + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'launchdarkly-observability' +``` + +And then execute: + +```bash +bundle install +``` + +Or install it yourself as: + +```bash +gem install launchdarkly-observability +``` + +### Dependencies + +The gem requires: +- `launchdarkly-server-sdk` >= 8.0 +- `opentelemetry-sdk` ~> 1.4 +- `opentelemetry-exporter-otlp` ~> 0.28 +- `opentelemetry-instrumentation-all` ~> 0.62 + +For logs and metrics support (optional): +- `opentelemetry-logs-sdk` ~> 0.1 +- `opentelemetry-metrics-sdk` ~> 0.1 + +## Quick Start + +### Basic Usage (Non-Rails) + +```ruby +require 'launchdarkly-server-sdk' +require 'launchdarkly_observability' + +# Create observability plugin +observability = LaunchDarklyObservability::Plugin.new( + project_id: 'your-launchdarkly-project-id', + environment: 'production' +) + +# Initialize LaunchDarkly client with plugin +config = LaunchDarkly::Config.new(plugins: [observability]) +client = LaunchDarkly::LDClient.new('your-sdk-key', config) + +# Flag evaluations are now automatically instrumented +context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) +value = client.variation('my-feature-flag', context, false) +``` + +### Rails Usage + +Create an initializer at `config/initializers/launchdarkly.rb`: + +```ruby +require 'launchdarkly-server-sdk' +require 'launchdarkly_observability' + +# Setup observability plugin +observability = LaunchDarklyObservability::Plugin.new( + project_id: ENV['LAUNCHDARKLY_PROJECT_ID'], + environment: Rails.env, + service_name: 'my-rails-app', + service_version: '1.0.0' +) + +# Initialize LaunchDarkly client +$ld_client = LaunchDarkly::LDClient.new( + ENV['LAUNCHDARKLY_SDK_KEY'], + LaunchDarkly::Config.new(plugins: [observability]) +) + +# Ensure clean shutdown +at_exit { $ld_client.close } +``` + +Use in controllers: + +```ruby +class ApplicationController < ActionController::Base + def current_ld_context + @current_ld_context ||= LaunchDarkly::LDContext.create({ + key: current_user&.id || 'anonymous', + kind: 'user', + email: current_user&.email, + name: current_user&.name + }) + end +end + +class HomeController < ApplicationController + def index + # This evaluation is automatically traced and correlated with the HTTP request + @show_new_feature = $ld_client.variation('new-feature', current_ld_context, false) + end +end +``` + +## Configuration + +### Plugin Options + +```ruby +LaunchDarklyObservability::Plugin.new( + # Required: LaunchDarkly project ID for telemetry routing + project_id: 'your-project-id', + + # Optional: Custom OTLP endpoint (default: LaunchDarkly's endpoint) + otlp_endpoint: 'https://otel.observability.app.launchdarkly.com:4318', + + # Optional: Deployment environment name + environment: 'production', + + # Optional: Service identification + service_name: 'my-service', + service_version: '1.0.0', + + # Optional: Enable/disable signal types + enable_traces: true, # default: true + enable_logs: true, # default: true + enable_metrics: true, # default: true + + # Optional: Custom instrumentation configuration + instrumentations: { + 'OpenTelemetry::Instrumentation::Rails' => { enable_recognize_route: true }, + 'OpenTelemetry::Instrumentation::ActiveRecord' => { db_statement: :include } + } +) +``` + +### Environment Variables + +You can also configure via environment variables: + +| Variable | Description | +|----------|-------------| +| `LAUNCHDARKLY_PROJECT_ID` | LaunchDarkly project ID | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Custom OTLP endpoint | +| `OTEL_SERVICE_NAME` | Service name | + +## Telemetry Details + +### Span Attributes + +Each flag evaluation creates a span with the following attributes: + +| Attribute | Description | Example | +|-----------|-------------|---------| +| `feature_flag.key` | Flag key | `"my-feature"` | +| `feature_flag.provider_name` | Provider name | `"LaunchDarkly"` | +| `feature_flag.value` | Evaluated value | `"true"` | +| `feature_flag.value.type` | Value type | `"TrueClass"` | +| `feature_flag.variant` | Variation index | `"1"` | +| `feature_flag.context.kind` | Context kind | `"user"` | +| `feature_flag.context.key` | Context key | `"user-123"` | +| `feature_flag.reason.kind` | Evaluation reason | `"FALLTHROUGH"` | +| `feature_flag.evaluation.duration_ms` | Evaluation time | `0.5` | +| `feature_flag.evaluation.method` | SDK method called | `"variation"` | + +### Error Tracking + +When evaluation errors occur, additional attributes are added: + +| Attribute | Description | Example | +|-----------|-------------|---------| +| `feature_flag.error` | Error kind | `"FLAG_NOT_FOUND"` | +| `feature_flag.reason.error_kind` | Detailed error | `"FLAG_NOT_FOUND"` | + +The span status is also set to `ERROR` with a descriptive message. + +### Rails Integration + +When used with Rails, the plugin provides: + +1. **Rack Middleware**: Automatically traces HTTP requests and provides context propagation +2. **Controller Helpers**: Convenient methods for custom tracing +3. **View Helpers**: Generate traceparent meta tags for client-side correlation + +#### Controller Helpers + +```ruby +class MyController < ApplicationController + def index + # Get current trace ID for logging + trace_id = launchdarkly_trace_id + Rails.logger.info "Processing request with trace: #{trace_id}" + + # Create custom spans + with_launchdarkly_span('custom-operation', attributes: { 'custom.key' => 'value' }) do |span| + # Your code here + span.set_attribute('result', 'success') + end + end + + def create + # Record exceptions + begin + process_something + rescue => e + record_launchdarkly_exception(e) + raise + end + end +end +``` + +#### View Helpers + +```erb + + <%= launchdarkly_traceparent_meta_tag %> + +``` + +This generates: +```html + +``` + +## Auto-Instrumentation + +By default, the plugin enables OpenTelemetry auto-instrumentation for common Ruby libraries: + +- **Rails**: Request tracing, route recognition +- **ActiveRecord**: Database query tracing +- **Net::HTTP**: Outbound HTTP request tracing +- **Rack**: Request/response tracing +- **Redis**: Cache operation tracing +- **Sidekiq**: Background job tracing + +### Customizing Instrumentations + +```ruby +LaunchDarklyObservability::Plugin.new( + project_id: 'my-project', + instrumentations: { + # Disable specific instrumentations + 'OpenTelemetry::Instrumentation::Redis' => { enabled: false }, + + # Configure instrumentations + 'OpenTelemetry::Instrumentation::ActiveRecord' => { + db_statement: :obfuscate, # Mask sensitive data + obfuscation_limit: 2000 + }, + + # Skip certain endpoints + 'OpenTelemetry::Instrumentation::Rack' => { + untraced_endpoints: ['/health', '/metrics'] + } + } +) +``` + +## Manual Instrumentation + +### Creating Custom Spans + +```ruby +tracer = OpenTelemetry.tracer_provider.tracer('my-component') + +tracer.in_span('custom-operation') do |span| + span.set_attribute('custom.attribute', 'value') + + # Your code here + + if error_occurred + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error('Operation failed') + end +end +``` + +### Logging with Trace Context + +```ruby +# Logs are automatically correlated with traces via the Logger instrumentation +Rails.logger.info "Processing flag evaluation" # Includes trace_id, span_id +``` + +## Troubleshooting + +### Spans Not Appearing + +1. Verify the OTLP endpoint is accessible: + ```ruby + puts LaunchDarklyObservability.instance&.otlp_endpoint + ``` + +2. Check if OpenTelemetry is configured: + ```ruby + puts OpenTelemetry.tracer_provider.class + # Should be: OpenTelemetry::SDK::Trace::TracerProvider + ``` + +3. Ensure the plugin is registered: + ```ruby + puts LaunchDarklyObservability.instance&.registered? + ``` + +### Missing Flag Evaluations + +Verify the hook is receiving evaluations by checking logs: +```ruby +# Set environment variable for debug output +ENV['OTEL_LOG_LEVEL'] = 'debug' +``` + +### Rails Middleware Not Active + +Ensure the gem is loaded in your Gemfile and the initializer runs before controllers: +```ruby +# Gemfile +gem 'launchdarkly_observability', require: true +``` + +## Testing + +When testing, you may want to use an in-memory exporter: + +```ruby +# test/test_helper.rb +require 'opentelemetry/sdk' + +class ActiveSupport::TestCase + setup do + @exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + ) + end + end + + teardown do + @exporter.reset + end + + def finished_spans + @exporter.finished_spans + end +end +``` + +## API Reference + +### LaunchDarklyObservability Module + +```ruby +# Initialize the plugin (alternative to creating Plugin directly) +LaunchDarklyObservability.init(project_id: 'my-project', environment: 'prod') + +# Check if initialized +LaunchDarklyObservability.initialized? # => true + +# Flush pending telemetry +LaunchDarklyObservability.flush + +# Shutdown (flushes and stops) +LaunchDarklyObservability.shutdown +``` + +### Plugin Class + +```ruby +plugin = LaunchDarklyObservability::Plugin.new(project_id: 'my-project') + +plugin.project_id # => 'my-project' +plugin.otlp_endpoint # => 'https://otel...' +plugin.environment # => '' +plugin.registered? # => false (true after client initialization) +plugin.flush # Flush pending data +plugin.shutdown # Stop the plugin +``` + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Write tests for your changes +4. Run tests (`bundle exec rake test`) +5. Commit your changes (`git commit -am 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +## License + +This project is licensed under the Apache 2.0 License - see the [LICENSE.txt](LICENSE.txt) file for details. + +## Support + +- [LaunchDarkly Documentation](https://docs.launchdarkly.com) +- [OpenTelemetry Ruby Documentation](https://opentelemetry.io/docs/instrumentation/ruby/) +- [GitHub Issues](https://github.com/launchdarkly/observability-sdk/issues) diff --git a/sdk/@launchdarkly/observability-ruby/Rakefile b/sdk/@launchdarkly/observability-ruby/Rakefile new file mode 100644 index 000000000..272c87ae6 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/Rakefile @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/**/*_test.rb'] +end + +require 'rubocop/rake_task' + +RuboCop::RakeTask.new + +task default: %i[test rubocop] diff --git a/sdk/@launchdarkly/observability-ruby/launchdarkly-observability.gemspec b/sdk/@launchdarkly/observability-ruby/launchdarkly-observability.gemspec new file mode 100644 index 000000000..c331d574e --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/launchdarkly-observability.gemspec @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'lib/launchdarkly_observability/version' + +Gem::Specification.new do |spec| + spec.name = 'launchdarkly-observability' + spec.version = LaunchDarklyObservability::VERSION + spec.authors = ['LaunchDarkly'] + spec.email = ['support@launchdarkly.com'] + + spec.summary = 'LaunchDarkly Observability Plugin for the Ruby SDK' + spec.description = 'OpenTelemetry-based observability instrumentation for LaunchDarkly Ruby SDK with Rails support' + spec.homepage = 'https://launchdarkly.com' + spec.license = 'Apache-2.0' + spec.required_ruby_version = Gem::Requirement.new('>= 3.0.0') + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/launchdarkly/observability-sdk' + spec.metadata['changelog_uri'] = 'https://github.com/launchdarkly/observability-sdk/blob/main/sdk/launchdarkly-observability/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'true' + + # Specify which files should be added to the gem when it is released. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + Dir['{lib}/**/*', 'LICENSE.txt', 'README.md', 'CHANGELOG.md'].reject { |f| File.directory?(f) } + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + # Runtime dependencies + spec.add_dependency 'launchdarkly-server-sdk', '>= 8.0' + spec.add_dependency 'opentelemetry-exporter-otlp', '~> 0.28' + spec.add_dependency 'opentelemetry-instrumentation-all', '~> 0.62' + spec.add_dependency 'opentelemetry-sdk', '~> 1.4' + spec.add_dependency 'opentelemetry-semantic_conventions', '~> 1.10' + + # Optional dependencies for logs/metrics (these may need to be required separately) + # spec.add_dependency 'opentelemetry-logs-sdk', '~> 0.1' + # spec.add_dependency 'opentelemetry-exporter-otlp-logs', '~> 0.1' + + # Development dependencies + spec.add_development_dependency 'minitest', '~> 5.0' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rubocop', '~> 1.0' +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb new file mode 100644 index 000000000..c6372707c --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'opentelemetry/sdk' +require 'opentelemetry/exporter/otlp' +require 'opentelemetry/instrumentation/all' +require 'opentelemetry/semantic_conventions' + +require_relative 'launchdarkly_observability/version' +require_relative 'launchdarkly_observability/hook' +require_relative 'launchdarkly_observability/opentelemetry_config' +require_relative 'launchdarkly_observability/plugin' + +# Load Rails integration if Rails is available +require_relative 'launchdarkly_observability/rails' if defined?(::Rails::Railtie) + +module LaunchDarklyObservability + # Default OTLP endpoint for LaunchDarkly Observability + DEFAULT_ENDPOINT = 'https://otel.observability.app.launchdarkly.com:4318' + + # Resource attribute keys + PROJECT_ID_ATTRIBUTE = 'launchdarkly.project_id' + SDK_NAME_ATTRIBUTE = 'telemetry.sdk.name' + SDK_VERSION_ATTRIBUTE = 'telemetry.sdk.version' + SDK_LANGUAGE_ATTRIBUTE = 'telemetry.sdk.language' + DISTRO_NAME_ATTRIBUTE = 'telemetry.distro.name' + DISTRO_VERSION_ATTRIBUTE = 'telemetry.distro.version' + + # Semantic convention attribute keys for feature flags + FEATURE_FLAG_KEY = 'feature_flag.key' + FEATURE_FLAG_VARIANT = 'feature_flag.variant' + FEATURE_FLAG_PROVIDER = 'feature_flag.provider_name' + + class << self + # @return [Plugin, nil] The current plugin instance + attr_reader :instance + + # Initialize the observability plugin + # + # @param project_id [String] LaunchDarkly project ID (required) + # @param options [Hash] Additional configuration options + # @option options [String] :otlp_endpoint Custom OTLP endpoint URL + # @option options [String] :environment Deployment environment name + # @option options [String] :service_name Service name for traces + # @option options [String] :service_version Service version + # @option options [Hash] :instrumentations Configuration for auto-instrumentations + # @return [Plugin] The initialized plugin + def init(project_id:, **options) + @instance = Plugin.new(project_id: project_id, **options) + end + + # Check if the plugin has been initialized + # + # @return [Boolean] true if initialized + def initialized? + !@instance.nil? + end + + # Flush all pending telemetry data + def flush + @instance&.flush + end + + # Shutdown the plugin and flush remaining data + def shutdown + @instance&.shutdown + @instance = nil + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb new file mode 100644 index 000000000..731c3e307 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'launchdarkly-server-sdk' + +module LaunchDarklyObservability + # Evaluation hook that instruments LaunchDarkly flag evaluations with OpenTelemetry spans. + # + # This hook creates spans for each flag evaluation, capturing: + # - Flag key and evaluation method + # - Context information (kind, key) + # - Evaluation result (value, variation index, reason) + # - Duration and any errors + # + # @example The hook is automatically registered when using the Plugin + # plugin = LaunchDarklyObservability::Plugin.new(project_id: 'my-project') + # config = LaunchDarkly::Config.new(plugins: [plugin]) + # client = LaunchDarkly::LDClient.new('sdk-key', config) + # + # # Flag evaluations are now automatically traced + # client.variation('my-flag', context, false) + # + class Hook + include LaunchDarkly::Interfaces::Hooks::Hook + + # Tracer name for OpenTelemetry spans + TRACER_NAME = 'launchdarkly-ruby' + + # Span name prefix + SPAN_PREFIX = 'launchdarkly' + + # Returns metadata about this hook + # + # @return [LaunchDarkly::Interfaces::Hooks::Metadata] + def metadata + LaunchDarkly::Interfaces::Hooks::Metadata.new('launchdarkly-observability-hook') + end + + # Called before flag evaluation + # + # Creates an OpenTelemetry span and captures initial context information. + # + # @param series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext] + # @param data [Hash] Data passed between hook stages + # @return [Hash] Updated data hash with span information + def before_evaluation(series_context, data) + return data unless opentelemetry_available? + + tracer = OpenTelemetry.tracer_provider.tracer(TRACER_NAME, LaunchDarklyObservability::VERSION) + span_name = "#{SPAN_PREFIX}.#{series_context.method}" + + span = tracer.start_span(span_name, attributes: build_before_attributes(series_context)) + + data.merge( + __ld_observability_span: span, + __ld_observability_start_time: monotonic_time + ) + rescue StandardError => e + # Don't let instrumentation errors affect the evaluation + warn "[LaunchDarklyObservability] Error in before_evaluation: #{e.message}" + data + end + + # Called after flag evaluation + # + # Completes the span with evaluation results and timing information. + # + # @param series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext] + # @param data [Hash] Data passed between hook stages + # @param detail [LaunchDarkly::EvaluationDetail] The evaluation result + # @return [Hash] Updated data hash + def after_evaluation(series_context, data, detail) + span = data[:__ld_observability_span] + return data unless span + + start_time = data[:__ld_observability_start_time] + + # Add result attributes + add_result_attributes(span, detail) + + # Add duration if we have a start time + if start_time + duration_ms = ((monotonic_time - start_time) * 1000).round(3) + span.set_attribute('feature_flag.evaluation.duration_ms', duration_ms) + end + + # Handle errors + handle_evaluation_error(span, detail) + + span.finish + + data + rescue StandardError => e + # Don't let instrumentation errors affect the evaluation + warn "[LaunchDarklyObservability] Error in after_evaluation: #{e.message}" + span&.finish + data + end + + private + + def opentelemetry_available? + defined?(OpenTelemetry) && OpenTelemetry.tracer_provider + end + + def monotonic_time + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def build_before_attributes(series_context) + attrs = { + FEATURE_FLAG_KEY => series_context.key, + FEATURE_FLAG_PROVIDER => 'LaunchDarkly', + 'feature_flag.evaluation.method' => series_context.method.to_s + } + + # Add context information safely + context = series_context.context + if context + attrs['feature_flag.context.kind'] = extract_context_kind(context) + attrs['feature_flag.context.key'] = extract_context_key(context) + end + + # Add default value type + default_value = series_context.default_value + attrs['feature_flag.default_value.type'] = default_value.class.name unless default_value.nil? + + attrs + end + + def extract_context_kind(context) + if context.respond_to?(:kind) + context.kind.to_s + elsif context.respond_to?(:kinds) + context.kinds.join(',') + else + 'unknown' + end + end + + def extract_context_key(context) + if context.respond_to?(:key) + context.key.to_s + elsif context.respond_to?(:keys) + # For multi-context, join the keys + context.keys.values.join(',') + else + 'unknown' + end + end + + def add_result_attributes(span, detail) + # Variation index (if available) + span.set_attribute(FEATURE_FLAG_VARIANT, detail.variation_index.to_s) if detail.variation_index + + # Value - convert to string for safe attribute value + value = detail.value + value_str = case value + when String, Numeric, TrueClass, FalseClass, NilClass + value.to_s + when Hash, Array + value.to_json + else + value.to_s + end + span.set_attribute('feature_flag.value', value_str) + span.set_attribute('feature_flag.value.type', value.class.name) + + # Evaluation reason + reason = detail.reason + return unless reason + + span.set_attribute('feature_flag.reason.kind', reason.kind.to_s) if reason.respond_to?(:kind) + + # Additional reason details based on kind + add_reason_details(span, reason) + end + + def add_reason_details(span, reason) + return unless reason.respond_to?(:kind) + + case reason.kind + when :RULE_MATCH + span.set_attribute('feature_flag.reason.rule_index', reason.rule_index) if reason.respond_to?(:rule_index) + span.set_attribute('feature_flag.reason.rule_id', reason.rule_id) if reason.respond_to?(:rule_id) + when :PREREQUISITE_FAILED + if reason.respond_to?(:prerequisite_key) + span.set_attribute('feature_flag.reason.prerequisite_key', + reason.prerequisite_key) + end + when :ERROR + span.set_attribute('feature_flag.reason.error_kind', reason.error_kind.to_s) if reason.respond_to?(:error_kind) + end + + # In experiment flag + span.set_attribute('feature_flag.reason.in_experiment', reason.in_experiment) if reason.respond_to?(:in_experiment) + end + + def handle_evaluation_error(span, detail) + reason = detail.reason + return unless reason&.respond_to?(:kind) && reason.kind == :ERROR + + error_kind = reason.respond_to?(:error_kind) ? reason.error_kind.to_s : 'UNKNOWN' + span.set_attribute('feature_flag.error', error_kind) + span.status = OpenTelemetry::Trace::Status.error("Flag evaluation error: #{error_kind}") + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb new file mode 100644 index 000000000..7545a6833 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require 'opentelemetry/sdk' +require 'opentelemetry/exporter/otlp' +require 'opentelemetry/instrumentation/all' +require 'opentelemetry/semantic_conventions' + +module LaunchDarklyObservability + # Configures OpenTelemetry SDK with appropriate providers and exporters + # for traces, logs, and metrics. + # + # This class handles the setup of: + # - Tracer provider with OTLP exporter and batch processing + # - Logger provider with OTLP log exporter (if available) + # - Meter provider with OTLP metrics exporter (if available) + # - Auto-instrumentation for Rails, ActiveRecord, Net::HTTP, etc. + # + class OpenTelemetryConfig + # Default batch processor configuration + BATCH_SCHEDULE_DELAY_MS = 1000 + BATCH_MAX_EXPORT_SIZE = 128 + BATCH_MAX_QUEUE_SIZE = 1024 + + # Metrics export interval + METRICS_EXPORT_INTERVAL_MS = 60_000 + + # @return [String] The LaunchDarkly project ID + attr_reader :project_id + + # @return [String] The OTLP endpoint + attr_reader :otlp_endpoint + + # @return [String] The deployment environment + attr_reader :environment + + # Initialize OpenTelemetry configuration + # + # @param project_id [String] LaunchDarkly project ID + # @param otlp_endpoint [String] OTLP collector endpoint + # @param environment [String] Deployment environment name + # @param sdk_metadata [LaunchDarkly::Interfaces::Plugins::SdkMetadata, nil] + # @param options [Hash] Additional options + def initialize(project_id:, otlp_endpoint:, environment:, sdk_metadata: nil, **options) + @project_id = project_id + @otlp_endpoint = otlp_endpoint + @environment = environment + @sdk_metadata = sdk_metadata + @options = options + @configured = false + @logger_provider = nil + @meter_provider = nil + end + + # Configure OpenTelemetry SDK + # + # Sets up tracer provider with OTLP exporter, and optionally + # logger and meter providers if the required gems are available. + def configure + return if @configured + + configure_traces if @options.fetch(:enable_traces, true) + configure_logs if @options.fetch(:enable_logs, true) + configure_metrics if @options.fetch(:enable_metrics, true) + + setup_shutdown_hook + + @configured = true + end + + # Flush all pending telemetry data + def flush + OpenTelemetry.tracer_provider&.force_flush + @logger_provider&.force_flush + @meter_provider&.force_flush + rescue StandardError => e + warn "[LaunchDarklyObservability] Error flushing telemetry: #{e.message}" + end + + # Shutdown all providers + def shutdown + OpenTelemetry.tracer_provider&.shutdown + @logger_provider&.shutdown + @meter_provider&.shutdown + rescue StandardError => e + warn "[LaunchDarklyObservability] Error shutting down telemetry: #{e.message}" + end + + private + + # Configure OpenTelemetry traces with OTLP exporter + def configure_traces + OpenTelemetry::SDK.configure do |c| + c.resource = create_resource + c.add_span_processor(create_batch_span_processor) + + # Enable auto-instrumentation + configure_instrumentations(c) + end + end + + # Configure auto-instrumentations + def configure_instrumentations(config) + instrumentation_config = @options.fetch(:instrumentations, {}) + + if instrumentation_config.empty? + # Use all available instrumentations with sensible defaults + config.use_all( + 'OpenTelemetry::Instrumentation::Rails' => { enable_recognize_route: true }, + 'OpenTelemetry::Instrumentation::ActiveRecord' => { db_statement: :include }, + 'OpenTelemetry::Instrumentation::Net::HTTP' => { untraced_hosts: [] }, + 'OpenTelemetry::Instrumentation::Rack' => { untraced_endpoints: ['/health', '/healthz', '/ready'] } + ) + else + config.use_all(instrumentation_config) + end + rescue StandardError => e + warn "[LaunchDarklyObservability] Error configuring instrumentations: #{e.message}" + end + + # Configure OpenTelemetry logs with OTLP exporter + def configure_logs + # Check if logs SDK is available + return unless logs_sdk_available? + + require 'opentelemetry-logs-sdk' + require 'opentelemetry/exporter/otlp/logs' + + @logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: create_resource) + + logs_processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new( + create_logs_exporter, + schedule_delay: BATCH_SCHEDULE_DELAY_MS + ) + + @logger_provider.add_log_record_processor(logs_processor) + + # Set global logger provider if the method exists + OpenTelemetry.logger_provider = @logger_provider if OpenTelemetry.respond_to?(:logger_provider=) + rescue LoadError + # Logs SDK not available, skip log configuration + nil + rescue StandardError => e + warn "[LaunchDarklyObservability] Error configuring logs: #{e.message}" + end + + # Configure OpenTelemetry metrics with OTLP exporter + def configure_metrics + # Check if metrics SDK is available + return unless metrics_sdk_available? + + require 'opentelemetry-metrics-sdk' + require 'opentelemetry/exporter/otlp/metrics' + + metric_reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + create_metrics_exporter, + export_interval_millis: METRICS_EXPORT_INTERVAL_MS + ) + + @meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new( + resource: create_resource, + metric_readers: [metric_reader] + ) + + # Set global meter provider if the method exists + OpenTelemetry.meter_provider = @meter_provider if OpenTelemetry.respond_to?(:meter_provider=) + rescue LoadError + # Metrics SDK not available, skip metrics configuration + nil + rescue StandardError => e + warn "[LaunchDarklyObservability] Error configuring metrics: #{e.message}" + end + + # Create OpenTelemetry resource with LaunchDarkly attributes + def create_resource + attrs = { + PROJECT_ID_ATTRIBUTE => @project_id, + SDK_NAME_ATTRIBUTE => 'opentelemetry', + SDK_VERSION_ATTRIBUTE => OpenTelemetry::SDK::VERSION, + SDK_LANGUAGE_ATTRIBUTE => 'ruby', + DISTRO_NAME_ATTRIBUTE => 'launchdarkly-observability-ruby', + DISTRO_VERSION_ATTRIBUTE => LaunchDarklyObservability::VERSION, + OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => @environment + } + + # Add service name + service_name = @options[:service_name] || infer_service_name + attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME] = service_name if service_name + + # Add service version + service_version = @options[:service_version] + attrs[OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION] = service_version if service_version + + # Add SDK metadata if available + if @sdk_metadata + attrs['launchdarkly.sdk.name'] = @sdk_metadata.name if @sdk_metadata.respond_to?(:name) + attrs['launchdarkly.sdk.version'] = @sdk_metadata.version if @sdk_metadata.respond_to?(:version) + end + + OpenTelemetry::SDK::Resources::Resource.create(attrs) + end + + # Create batch span processor with OTLP exporter + def create_batch_span_processor + OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + create_trace_exporter, + schedule_delay: BATCH_SCHEDULE_DELAY_MS, + max_export_batch_size: BATCH_MAX_EXPORT_SIZE, + max_queue_size: BATCH_MAX_QUEUE_SIZE + ) + end + + # Create OTLP trace exporter + def create_trace_exporter + OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: "#{@otlp_endpoint}/v1/traces", + compression: 'gzip' + ) + end + + # Create OTLP logs exporter + def create_logs_exporter + OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new( + endpoint: "#{@otlp_endpoint}/v1/logs", + compression: 'gzip' + ) + end + + # Create OTLP metrics exporter + def create_metrics_exporter + OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + endpoint: "#{@otlp_endpoint}/v1/metrics", + compression: 'gzip' + ) + end + + # Infer service name from Rails or environment + def infer_service_name + if defined?(::Rails) && ::Rails.respond_to?(:application) + app_class = ::Rails.application.class + if app_class.respond_to?(:module_parent_name) + app_class.module_parent_name.underscore + else + app_class.parent_name&.underscore + end + else + ENV.fetch('OTEL_SERVICE_NAME', nil) + end + end + + # Check if logs SDK gem is available + def logs_sdk_available? + Gem::Specification.find_by_name('opentelemetry-logs-sdk') + true + rescue Gem::MissingSpecError + false + end + + # Check if metrics SDK gem is available + def metrics_sdk_available? + Gem::Specification.find_by_name('opentelemetry-metrics-sdk') + true + rescue Gem::MissingSpecError + false + end + + # Setup graceful shutdown hook + def setup_shutdown_hook + at_exit { shutdown } + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb new file mode 100644 index 000000000..beb4ed6a8 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'launchdarkly-server-sdk' + +module LaunchDarklyObservability + # LaunchDarkly SDK Plugin that provides observability instrumentation. + # + # This plugin integrates with the LaunchDarkly Ruby SDK to automatically + # instrument flag evaluations with OpenTelemetry traces, logs, and metrics. + # + # @example Basic usage + # plugin = LaunchDarklyObservability::Plugin.new( + # project_id: 'my-project-id', + # environment: 'production' + # ) + # + # config = LaunchDarkly::Config.new(plugins: [plugin]) + # client = LaunchDarkly::LDClient.new('sdk-key', config) + # + class Plugin + include LaunchDarkly::Interfaces::Plugins::Plugin + + # @return [String] The LaunchDarkly project ID + attr_reader :project_id + + # @return [String] The OTLP endpoint URL + attr_reader :otlp_endpoint + + # @return [String] The deployment environment + attr_reader :environment + + # @return [Hash] Additional options + attr_reader :options + + # Initialize a new observability plugin + # + # @param project_id [String] LaunchDarkly project ID (required for routing telemetry) + # @param otlp_endpoint [String] OTLP collector endpoint (default: LaunchDarkly's endpoint) + # @param environment [String] Deployment environment name (e.g., 'production', 'staging') + # @param options [Hash] Additional configuration options + # @option options [String] :service_name Service name for resource attributes + # @option options [String] :service_version Service version for resource attributes + # @option options [Hash] :instrumentations Configuration for OpenTelemetry auto-instrumentations + # @option options [Boolean] :enable_traces Enable trace instrumentation (default: true) + # @option options [Boolean] :enable_logs Enable log instrumentation (default: true) + # @option options [Boolean] :enable_metrics Enable metrics instrumentation (default: true) + def initialize(project_id:, otlp_endpoint: DEFAULT_ENDPOINT, environment: '', **options) + @project_id = project_id + @otlp_endpoint = otlp_endpoint + @environment = environment.to_s + @options = default_options.merge(options) + @hook = Hook.new + @otel_config = nil + @registered = false + end + + # Returns metadata about this plugin + # + # @return [LaunchDarkly::Interfaces::Plugins::PluginMetadata] + def metadata + LaunchDarkly::Interfaces::Plugins::PluginMetadata.new('launchdarkly-observability') + end + + # Returns the hooks provided by this plugin + # + # @param _environment_metadata [LaunchDarkly::Interfaces::Plugins::EnvironmentMetadata] + # @return [Array] + def get_hooks(_environment_metadata) + [@hook] + end + + # Register the plugin with the LaunchDarkly client + # + # This method is called during SDK initialization. It sets up the + # OpenTelemetry SDK with appropriate providers and exporters. + # + # @param _client [LaunchDarkly::LDClient] The LaunchDarkly client instance + # @param environment_metadata [LaunchDarkly::Interfaces::Plugins::EnvironmentMetadata] + def register(_client, environment_metadata) + return if @registered + + @otel_config = OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + sdk_metadata: environment_metadata&.sdk, + **@options + ) + + @otel_config.configure + + @registered = true + end + + # Check if the plugin has been registered + # + # @return [Boolean] + def registered? + @registered + end + + # Flush all pending telemetry data + def flush + @otel_config&.flush + end + + # Shutdown the plugin and flush remaining data + def shutdown + @otel_config&.shutdown + @registered = false + end + + private + + def default_options + { + enable_traces: true, + enable_logs: true, + enable_metrics: true, + service_name: nil, + service_version: nil, + instrumentations: {} + } + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/rails.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/rails.rb new file mode 100644 index 000000000..b82e42417 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/rails.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module LaunchDarklyObservability + # Rack middleware for request tracing + # + # This middleware wraps incoming HTTP requests in OpenTelemetry spans, + # providing context for flag evaluations that occur during request processing. + # + # The middleware is automatically installed by the Railtie when Rails is detected. + # + # @note This middleware is complementary to OpenTelemetry's Rails instrumentation. + # It adds LaunchDarkly-specific context propagation and request ID tracking. + # + class Middleware + # Header for highlight/observability request context + HIGHLIGHT_REQUEST_HEADER = 'HTTP_X_HIGHLIGHT_REQUEST' + + # Baggage keys for context propagation + SESSION_BAGGAGE_KEY = 'launchdarkly.session_id' + REQUEST_BAGGAGE_KEY = 'launchdarkly.request_id' + + def initialize(app) + @app = app + end + + # Process the request with tracing context + # + # @param env [Hash] Rack environment + # @return [Array] Rack response tuple + def call(env) + return @app.call(env) unless tracing_available? + + request = Rack::Request.new(env) + + # Extract session/request IDs from headers (if present) + session_id, request_id = extract_highlight_context(env) + + # Set baggage for downstream spans + ctx = set_baggage_context(session_id, request_id) + + OpenTelemetry::Context.with_current(ctx) do + tracer.in_span(span_name(request), attributes: request_attributes(request)) do |span| + # Add session/request IDs as span attributes + span.set_attribute(SESSION_BAGGAGE_KEY, session_id) if session_id + span.set_attribute(REQUEST_BAGGAGE_KEY, request_id) if request_id + + status, headers, body = @app.call(env) + + # Add response attributes + span.set_attribute('http.status_code', status) + span.status = OpenTelemetry::Trace::Status.error("HTTP #{status}") if status >= 500 + + [status, headers, body] + end + end + rescue StandardError => e + # Don't let middleware errors break the request + warn "[LaunchDarklyObservability] Middleware error: #{e.message}" + @app.call(env) + end + + private + + def tracing_available? + defined?(OpenTelemetry) && OpenTelemetry.tracer_provider + end + + def tracer + @tracer ||= OpenTelemetry.tracer_provider.tracer( + 'launchdarkly-ruby-rails', + LaunchDarklyObservability::VERSION + ) + end + + def span_name(request) + "#{request.request_method} #{request.path}" + end + + def request_attributes(request) + { + 'http.method' => request.request_method, + 'http.url' => request.url, + 'http.target' => request.path, + 'http.host' => request.host, + 'http.scheme' => request.scheme, + 'http.user_agent' => request.user_agent + }.compact + end + + def extract_highlight_context(env) + header_value = env[HIGHLIGHT_REQUEST_HEADER] + return [nil, nil] unless header_value + + parts = header_value.to_s.split('/') + session_id = parts[0].presence + request_id = parts[1].presence + + [session_id, request_id] + end + + def set_baggage_context(session_id, request_id) + ctx = OpenTelemetry::Context.current + ctx = OpenTelemetry::Baggage.set_value(SESSION_BAGGAGE_KEY, session_id, context: ctx) if session_id + ctx = OpenTelemetry::Baggage.set_value(REQUEST_BAGGAGE_KEY, request_id, context: ctx) if request_id + ctx + end + end + + # Rails Railtie for automatic integration + # + # This Railtie automatically: + # - Inserts the LaunchDarkly middleware into the Rails middleware stack + # - Configures Rails.logger to export to OpenTelemetry (if logger provider is available) + # - Provides helper methods for controllers + # + # @example The Railtie is automatically loaded when Rails is detected + # # In config/initializers/launchdarkly.rb + # LaunchDarklyObservability.init(project_id: ENV['LD_PROJECT_ID']) + # + class Railtie < ::Rails::Railtie + initializer 'launchdarkly_observability.configure_rails' do |app| + # Insert middleware early in the stack for context propagation + app.middleware.insert_before(0, LaunchDarklyObservability::Middleware) + end + + config.after_initialize do + # Include controller helpers in ActionController + if defined?(ActionController::Base) + ActionController::Base.include(LaunchDarklyObservability::ControllerHelpers) + end + + if defined?(ActionController::API) + ActionController::API.include(LaunchDarklyObservability::ControllerHelpers) + end + end + end + + # Controller helper methods for Rails + # + # These helpers provide convenient access to observability features + # within Rails controllers. + # + module ControllerHelpers + extend ActiveSupport::Concern + + included do + helper_method :launchdarkly_trace_id if respond_to?(:helper_method) + end + + # Get the current trace ID (useful for logging/debugging) + # + # @return [String, nil] The current OpenTelemetry trace ID + def launchdarkly_trace_id + return nil unless defined?(OpenTelemetry) + + span = OpenTelemetry::Trace.current_span + return nil unless span&.context&.valid? + + span.context.hex_trace_id + end + + # Create a custom span for tracing specific operations + # + # @param name [String] The span name + # @param attributes [Hash] Span attributes + # @yield [span] Block to execute within the span + # @return The result of the block + def with_launchdarkly_span(name, attributes: {}) + return yield unless defined?(OpenTelemetry) && OpenTelemetry.tracer_provider + + tracer = OpenTelemetry.tracer_provider.tracer( + 'launchdarkly-ruby-rails', + LaunchDarklyObservability::VERSION + ) + + tracer.in_span(name, attributes: attributes) do |span| + yield(span) + end + end + + # Record an exception in the current span + # + # @param exception [Exception] The exception to record + # @param attributes [Hash] Additional attributes + def record_launchdarkly_exception(exception, attributes: {}) + return unless defined?(OpenTelemetry) + + span = OpenTelemetry::Trace.current_span + return unless span + + span.record_exception(exception, attributes: attributes) + span.status = OpenTelemetry::Trace::Status.error(exception.message) + end + end + + # View helpers for Rails + # + # These helpers can be used in views to inject tracing context + # into the rendered HTML for client-side correlation. + # + module ViewHelpers + # Generate a meta tag with the current traceparent + # + # This is useful for correlating server-side traces with client-side + # observability data. + # + # @return [String] HTML meta tag with traceparent value + def launchdarkly_traceparent_meta_tag + traceparent = launchdarkly_traceparent + return '' unless traceparent + + tag.meta(name: 'traceparent', content: traceparent) + end + + # Get the current W3C traceparent value + # + # @return [String, nil] The traceparent header value + def launchdarkly_traceparent + return nil unless defined?(OpenTelemetry) + + span = OpenTelemetry::Trace.current_span + return nil unless span&.context&.valid? + + trace_id = span.context.hex_trace_id + span_id = span.context.hex_span_id + trace_flags = span.context.trace_flags.sampled? ? '01' : '00' + + "00-#{trace_id}-#{span_id}-#{trace_flags}" + end + end + + # Include view helpers in ActionView if available + if defined?(ActionView::Base) + ActionView::Base.include(ViewHelpers) + end +end diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/version.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/version.rb new file mode 100644 index 000000000..2e6600245 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module LaunchDarklyObservability + VERSION = '0.1.0' +end diff --git a/sdk/@launchdarkly/observability-ruby/test/hook_test.rb b/sdk/@launchdarkly/observability-ruby/test/hook_test.rb new file mode 100644 index 000000000..f0483a3a4 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/hook_test.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'test_helper' + +class HookTest < Minitest::Test + include TestHelper + + def setup + @hook = LaunchDarklyObservability::Hook.new + @exporter = create_test_exporter + + # Configure OpenTelemetry with in-memory exporter for testing + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + ) + end + end + + def teardown + @exporter.reset + end + + def test_metadata_returns_correct_name + metadata = @hook.metadata + assert_instance_of LaunchDarkly::Interfaces::Hooks::Metadata, metadata + assert_equal 'launchdarkly-observability-hook', metadata.name + end + + def test_before_evaluation_creates_span_data + series_context = create_series_context + data = {} + + result = @hook.before_evaluation(series_context, data) + + assert result.key?(:__ld_observability_span) + assert result.key?(:__ld_observability_start_time) + refute_nil result[:__ld_observability_span] + assert_kind_of Numeric, result[:__ld_observability_start_time] + end + + def test_before_evaluation_returns_data_when_otel_not_available + series_context = create_series_context + data = { existing: 'data' } + + # Temporarily remove OpenTelemetry + original_provider = OpenTelemetry.tracer_provider + OpenTelemetry.instance_variable_set(:@tracer_provider, nil) + + begin + result = @hook.before_evaluation(series_context, data) + assert_equal data, result + ensure + OpenTelemetry.instance_variable_set(:@tracer_provider, original_provider) + end + end + + def test_after_evaluation_finishes_span + series_context = create_series_context + detail = create_evaluation_detail + + # Run before to create span + data = @hook.before_evaluation(series_context, {}) + + # Run after to finish span + result = @hook.after_evaluation(series_context, data, detail) + + assert_equal data, result + + # Check that span was exported + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + assert_equal 'launchdarkly.variation', span.name + end + + def test_after_evaluation_adds_result_attributes + series_context = create_series_context + detail = create_evaluation_detail(value: true, variation_index: 1) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'true', span.attributes['feature_flag.value'] + assert_equal '1', span.attributes['feature_flag.variant'] + assert_equal 'FALLTHROUGH', span.attributes['feature_flag.reason.kind'] + end + + def test_after_evaluation_handles_error_reason + series_context = create_series_context + detail = create_error_detail(error_kind: :FLAG_NOT_FOUND) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'ERROR', span.attributes['feature_flag.reason.kind'] + assert_equal 'FLAG_NOT_FOUND', span.attributes['feature_flag.error'] + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code + end + + def test_after_evaluation_records_duration + series_context = create_series_context + detail = create_evaluation_detail + + data = @hook.before_evaluation(series_context, {}) + + # Small delay to ensure measurable duration + sleep(0.001) + + @hook.after_evaluation(series_context, data, detail) + + spans = @exporter.finished_spans + span = spans.first + + duration = span.attributes['feature_flag.evaluation.duration_ms'] + assert duration.positive?, "Duration should be positive, got #{duration}" + end + + def test_before_evaluation_captures_context_info + context = LaunchDarkly::LDContext.create({ key: 'user-456', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'my-flag', context, false, :variation + ) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'my-flag', span.attributes['feature_flag.key'] + assert_equal 'user', span.attributes['feature_flag.context.kind'] + assert_equal 'user-456', span.attributes['feature_flag.context.key'] + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider_name'] + end + + def test_after_evaluation_handles_hash_value + series_context = create_series_context + detail = create_evaluation_detail(value: { foo: 'bar', count: 42 }) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + spans = @exporter.finished_spans + span = spans.first + + # Hash should be JSON serialized + assert_includes span.attributes['feature_flag.value'], 'foo' + assert_equal 'Hash', span.attributes['feature_flag.value.type'] + end + + def test_after_evaluation_handles_missing_span + series_context = create_series_context + detail = create_evaluation_detail + data = {} # No span in data + + # Should not raise + result = @hook.after_evaluation(series_context, data, detail) + assert_equal data, result + end + + def test_span_name_includes_method + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'flag', create_ld_context, false, :variation_detail + ) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + spans = @exporter.finished_spans + assert_equal 'launchdarkly.variation_detail', spans.first.name + end +end diff --git a/sdk/@launchdarkly/observability-ruby/test/integration_test.rb b/sdk/@launchdarkly/observability-ruby/test/integration_test.rb new file mode 100644 index 000000000..983343d65 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/integration_test.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Integration tests that verify the full flow from LaunchDarkly client +# through to OpenTelemetry span export. +# +# These tests use a real (test-configured) LaunchDarkly client with +# test data to verify the complete instrumentation pipeline. +# +class IntegrationTest < Minitest::Test + include TestHelper + + def setup + @exporter = create_test_exporter + @project_id = 'integration-test-project' + + # Create plugin with test configuration + @plugin = LaunchDarklyObservability::Plugin.new( + project_id: @project_id, + otlp_endpoint: 'http://localhost:4318', + environment: 'test', + service_name: 'integration-test-service', + service_version: '1.0.0', + enable_logs: false, + enable_metrics: false + ) + end + + def teardown + @exporter.reset + @client&.close + end + + def test_full_evaluation_creates_span + # Configure OpenTelemetry with test exporter + configure_test_otel + + # Create LaunchDarkly client with test data + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag('test-flag').variations(false, true).variation_for_all(1)) + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + # Perform evaluation + context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) + result = @client.variation('test-flag', context, false) + + assert result, 'Expected flag to return true' + + # Verify span was created + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + assert_equal 'launchdarkly.variation', span.name + assert_equal 'test-flag', span.attributes['feature_flag.key'] + assert_equal 'user', span.attributes['feature_flag.context.kind'] + assert_equal 'user-123', span.attributes['feature_flag.context.key'] + assert_equal 'true', span.attributes['feature_flag.value'] + assert_equal '1', span.attributes['feature_flag.variant'] + end + + def test_variation_detail_creates_span_with_reason + configure_test_otel + + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag('detail-flag').variations('off', 'on').variation_for_all(1)) + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + context = LaunchDarkly::LDContext.create({ key: 'user-456', kind: 'user' }) + detail = @client.variation_detail('detail-flag', context, 'default') + + assert_equal 'on', detail.value + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'launchdarkly.variation_detail', span.name + assert_equal 'FALLTHROUGH', span.attributes['feature_flag.reason.kind'] + end + + def test_error_evaluation_creates_error_span + configure_test_otel + + # Create client with no flags configured + td = LaunchDarkly::Integrations::TestData.data_source + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + context = LaunchDarkly::LDContext.create({ key: 'user-789', kind: 'user' }) + detail = @client.variation_detail('nonexistent-flag', context, 'default') + + assert_equal 'default', detail.value + assert_equal :ERROR, detail.reason.kind + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'FLAG_NOT_FOUND', span.attributes['feature_flag.error'] + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code + end + + def test_multiple_evaluations_create_multiple_spans + configure_test_otel + + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag('flag-a').variations(false, true).variation_for_all(1)) + td.update(td.flag('flag-b').variations('a', 'b', 'c').variation_for_all(2)) + td.update(td.flag('flag-c').variations(0, 1, 2).variation_for_all(1)) + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + context = LaunchDarkly::LDContext.create({ key: 'multi-user', kind: 'user' }) + + @client.variation('flag-a', context, false) + @client.variation('flag-b', context, 'default') + @client.variation('flag-c', context, 0) + + spans = @exporter.finished_spans + assert_equal 3, spans.length + + flag_keys = spans.map { |s| s.attributes['feature_flag.key'] } + assert_includes flag_keys, 'flag-a' + assert_includes flag_keys, 'flag-b' + assert_includes flag_keys, 'flag-c' + end + + def test_multi_context_evaluation + configure_test_otel + + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag('multi-ctx-flag').variations(false, true).variation_for_all(1)) + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + # Create multi-context + user_context = LaunchDarkly::LDContext.create({ key: 'user-1', kind: 'user' }) + org_context = LaunchDarkly::LDContext.create({ key: 'org-1', kind: 'organization' }) + multi_context = LaunchDarkly::LDContext.create_multi([user_context, org_context]) + + @client.variation('multi-ctx-flag', multi_context, false) + + spans = @exporter.finished_spans + span = spans.first + + # Multi-context should have kinds joined + context_kind = span.attributes['feature_flag.context.kind'] + assert_includes context_kind, 'user' + assert_includes context_kind, 'organization' + end + + def test_json_flag_value_serialization + configure_test_otel + + td = LaunchDarkly::Integrations::TestData.data_source + json_value = { 'feature' => 'enabled', 'settings' => { 'limit' => 100 } } + td.update(td.flag('json-flag').variations({}, json_value).variation_for_all(1)) + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + context = LaunchDarkly::LDContext.create({ key: 'user-json', kind: 'user' }) + result = @client.variation('json-flag', context, {}) + + assert_equal json_value, result + + spans = @exporter.finished_spans + span = spans.first + + # JSON value should be serialized + assert_includes span.attributes['feature_flag.value'], 'feature' + assert_equal 'Hash', span.attributes['feature_flag.value.type'] + end + + def test_plugin_hook_registered_via_config + configure_test_otel + + td = LaunchDarkly::Integrations::TestData.data_source + td.update(td.flag('config-hook-flag').variations(false, true).variation_for_all(1)) + + # Verify hook is properly registered through plugin mechanism + hooks = @plugin.get_hooks(nil) + assert_equal 1, hooks.length + assert_instance_of LaunchDarklyObservability::Hook, hooks.first + + config = LaunchDarkly::Config.new( + data_source: td, + send_events: false, + plugins: [@plugin] + ) + + @client = LaunchDarkly::LDClient.new('fake-sdk-key', config) + + context = LaunchDarkly::LDContext.create({ key: 'hook-user', kind: 'user' }) + @client.variation('config-hook-flag', context, false) + + # Verify span was created (hook was invoked) + spans = @exporter.finished_spans + assert_equal 1, spans.length + end + + private + + def configure_test_otel + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + ) + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/test/middleware_test.rb b/sdk/@launchdarkly/observability-ruby/test/middleware_test.rb new file mode 100644 index 000000000..3d9de4237 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/middleware_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'rack' +require 'rack/test' + +class MiddlewareTest < Minitest::Test + include Rack::Test::Methods + include TestHelper + + def setup + @exporter = create_test_exporter + + # Configure OpenTelemetry with test exporter + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + ) + end + end + + def teardown + @exporter.reset + end + + def app + inner_app = lambda do |_env| + [200, { 'Content-Type' => 'text/plain' }, ['OK']] + end + + LaunchDarklyObservability::Middleware.new(inner_app) + end + + def test_middleware_creates_span_for_request + get '/test-path' + + assert last_response.ok? + + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + assert_equal 'GET /test-path', span.name + assert_equal 'GET', span.attributes['http.method'] + assert_includes span.attributes['http.url'], '/test-path' + end + + def test_middleware_records_status_code + get '/test-path' + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 200, span.attributes['http.status_code'] + end + + def test_middleware_extracts_highlight_context + header 'X-Highlight-Request', 'session-123/request-456' + get '/with-context' + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 'session-123', span.attributes['launchdarkly.session_id'] + assert_equal 'request-456', span.attributes['launchdarkly.request_id'] + end + + def test_middleware_handles_missing_highlight_header + get '/no-context' + + spans = @exporter.finished_spans + span = spans.first + + refute span.attributes.key?('launchdarkly.session_id') + refute span.attributes.key?('launchdarkly.request_id') + end + + def test_middleware_records_error_status + error_app = lambda do |_env| + [500, { 'Content-Type' => 'text/plain' }, ['Internal Server Error']] + end + + middleware = LaunchDarklyObservability::Middleware.new(error_app) + status, = middleware.call(Rack::MockRequest.env_for('/error')) + + assert_equal 500, status + + spans = @exporter.finished_spans + span = spans.first + + assert_equal 500, span.attributes['http.status_code'] + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code + end + + def test_middleware_captures_request_attributes + header 'User-Agent', 'Test-Agent/1.0' + get '/full-attributes' + + spans = @exporter.finished_spans + span = spans.first + + assert_equal '/full-attributes', span.attributes['http.target'] + assert_equal 'Test-Agent/1.0', span.attributes['http.user_agent'] + end + + def test_middleware_handles_app_exception + error_app = lambda do |_env| + raise StandardError, 'Test error' + end + + middleware = LaunchDarklyObservability::Middleware.new(error_app) + + assert_raises(StandardError) do + middleware.call(Rack::MockRequest.env_for('/exception')) + end + end + + def test_middleware_continues_without_otel + # Temporarily disable OpenTelemetry + original_provider = OpenTelemetry.tracer_provider + OpenTelemetry.instance_variable_set(:@tracer_provider, nil) + + begin + get '/no-otel' + assert last_response.ok? + ensure + OpenTelemetry.instance_variable_set(:@tracer_provider, original_provider) + end + end +end diff --git a/sdk/@launchdarkly/observability-ruby/test/opentelemetry_config_test.rb b/sdk/@launchdarkly/observability-ruby/test/opentelemetry_config_test.rb new file mode 100644 index 000000000..c61a9f461 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/opentelemetry_config_test.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OpenTelemetryConfigTest < Minitest::Test + include TestHelper + + def setup + @project_id = 'test-project-123' + @otlp_endpoint = 'https://test.endpoint.com:4318' + @environment = 'test' + end + + def test_initialize_stores_configuration + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment + ) + + assert_equal @project_id, config.project_id + assert_equal @otlp_endpoint, config.otlp_endpoint + assert_equal @environment, config.environment + end + + def test_configure_sets_up_tracer_provider + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + + # Verify tracer provider is available + tracer_provider = OpenTelemetry.tracer_provider + refute_nil tracer_provider + assert_kind_of OpenTelemetry::SDK::Trace::TracerProvider, tracer_provider + end + + def test_configure_only_runs_once + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + first_provider = OpenTelemetry.tracer_provider + + # Second configure should be a no-op + config.configure + second_provider = OpenTelemetry.tracer_provider + + # Should be the same provider instance + assert_same first_provider, second_provider + end + + def test_resource_includes_project_id + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + + resource = OpenTelemetry.tracer_provider.resource + assert_equal @project_id, resource.attributes['launchdarkly.project_id'] + end + + def test_resource_includes_environment + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: 'production', + enable_logs: false, + enable_metrics: false + ) + + config.configure + + resource = OpenTelemetry.tracer_provider.resource + assert_equal 'production', resource.attributes['deployment.environment'] + end + + def test_resource_includes_sdk_info + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + + resource = OpenTelemetry.tracer_provider.resource + assert_equal 'opentelemetry', resource.attributes['telemetry.sdk.name'] + assert_equal 'ruby', resource.attributes['telemetry.sdk.language'] + assert_equal 'launchdarkly-observability-ruby', resource.attributes['telemetry.distro.name'] + assert_equal LaunchDarklyObservability::VERSION, resource.attributes['telemetry.distro.version'] + end + + def test_resource_includes_service_name_when_provided + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + service_name: 'my-awesome-service', + enable_logs: false, + enable_metrics: false + ) + + config.configure + + resource = OpenTelemetry.tracer_provider.resource + assert_equal 'my-awesome-service', resource.attributes['service.name'] + end + + def test_resource_includes_service_version_when_provided + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + service_version: '2.0.0', + enable_logs: false, + enable_metrics: false + ) + + config.configure + + resource = OpenTelemetry.tracer_provider.resource + assert_equal '2.0.0', resource.attributes['service.version'] + end + + def test_flush_does_not_raise + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + + # Should not raise + config.flush + end + + def test_shutdown_does_not_raise + config = LaunchDarklyObservability::OpenTelemetryConfig.new( + project_id: @project_id, + otlp_endpoint: @otlp_endpoint, + environment: @environment, + enable_logs: false, + enable_metrics: false + ) + + config.configure + + # Should not raise + config.shutdown + end + + def test_batch_processor_configuration + # This test verifies the batch processor settings are applied + # by checking that spans are exported correctly + exporter = create_test_exporter + + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter) + ) + end + + tracer = OpenTelemetry.tracer_provider.tracer('test') + tracer.in_span('test-span') do |span| + span.set_attribute('test', 'value') + end + + spans = exporter.finished_spans + assert_equal 1, spans.length + assert_equal 'test-span', spans.first.name + end +end diff --git a/sdk/@launchdarkly/observability-ruby/test/plugin_test.rb b/sdk/@launchdarkly/observability-ruby/test/plugin_test.rb new file mode 100644 index 000000000..4388a8586 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/plugin_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PluginTest < Minitest::Test + include TestHelper + + def setup + @project_id = 'test-project-123' + end + + def test_initialize_with_required_params + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + + assert_equal @project_id, plugin.project_id + assert_equal LaunchDarklyObservability::DEFAULT_ENDPOINT, plugin.otlp_endpoint + assert_equal '', plugin.environment + end + + def test_initialize_with_all_params + endpoint = 'https://custom.endpoint.com:4318' + environment = 'production' + + plugin = LaunchDarklyObservability::Plugin.new( + project_id: @project_id, + otlp_endpoint: endpoint, + environment: environment, + service_name: 'my-service', + service_version: '1.0.0' + ) + + assert_equal @project_id, plugin.project_id + assert_equal endpoint, plugin.otlp_endpoint + assert_equal environment, plugin.environment + assert_equal 'my-service', plugin.options[:service_name] + assert_equal '1.0.0', plugin.options[:service_version] + end + + def test_metadata_returns_plugin_metadata + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + metadata = plugin.metadata + + assert_instance_of LaunchDarkly::Interfaces::Plugins::PluginMetadata, metadata + assert_equal 'launchdarkly-observability', metadata.name + end + + def test_get_hooks_returns_evaluation_hook + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + hooks = plugin.get_hooks(nil) + + assert_equal 1, hooks.length + assert_instance_of LaunchDarklyObservability::Hook, hooks.first + end + + def test_includes_plugin_mixin + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + + assert plugin.is_a?(LaunchDarkly::Interfaces::Plugins::Plugin) + end + + def test_not_registered_initially + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + + refute plugin.registered? + end + + def test_default_options + plugin = LaunchDarklyObservability::Plugin.new(project_id: @project_id) + + assert plugin.options[:enable_traces] + assert plugin.options[:enable_logs] + assert plugin.options[:enable_metrics] + assert_nil plugin.options[:service_name] + assert_nil plugin.options[:service_version] + assert_equal({}, plugin.options[:instrumentations]) + end + + def test_environment_converted_to_string + plugin = LaunchDarklyObservability::Plugin.new( + project_id: @project_id, + environment: :production + ) + + assert_equal 'production', plugin.environment + end + + def test_options_can_disable_signals + plugin = LaunchDarklyObservability::Plugin.new( + project_id: @project_id, + enable_traces: true, + enable_logs: false, + enable_metrics: false + ) + + assert plugin.options[:enable_traces] + refute plugin.options[:enable_logs] + refute plugin.options[:enable_metrics] + end + + def test_custom_instrumentations + custom_config = { + 'OpenTelemetry::Instrumentation::Rails' => { enabled: false } + } + + plugin = LaunchDarklyObservability::Plugin.new( + project_id: @project_id, + instrumentations: custom_config + ) + + assert_equal custom_config, plugin.options[:instrumentations] + end +end diff --git a/sdk/@launchdarkly/observability-ruby/test/test_helper.rb b/sdk/@launchdarkly/observability-ruby/test/test_helper.rb new file mode 100644 index 000000000..e2066f5e1 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/test_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'minitest/autorun' +require 'minitest/pride' + +# Mock OpenTelemetry before loading our gem +require 'opentelemetry/sdk' +require 'opentelemetry/exporter/otlp' + +# Load our gem +require 'launchdarkly_observability' + +# Test helper module +module TestHelper + # Create a mock EvaluationSeriesContext + def create_series_context(key: 'test-flag', method: :variation, default_value: false) + context = create_ld_context + LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default_value, method) + end + + # Create a mock LDContext + def create_ld_context(key: 'user-123', kind: 'user') + LaunchDarkly::LDContext.create({ key: key, kind: kind }) + end + + # Create a mock EvaluationDetail + def create_evaluation_detail(value: true, variation_index: 1, reason: nil) + reason ||= LaunchDarkly::EvaluationReason.fallthrough + LaunchDarkly::EvaluationDetail.new(value, variation_index, reason) + end + + # Create an error EvaluationDetail + def create_error_detail(error_kind: :FLAG_NOT_FOUND) + reason = LaunchDarkly::EvaluationReason.error(error_kind) + LaunchDarkly::EvaluationDetail.new(nil, nil, reason) + end + + # Reset OpenTelemetry state between tests + def reset_opentelemetry + # Reset the tracer provider to a new SDK instance + OpenTelemetry::SDK.configure do |c| + c.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new( + OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + )) + end + end + + # Get an in-memory span exporter for testing + def create_test_exporter + OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + end +end From 3bd3a317b9b9631292384d1d499350835734fbf3 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 09:25:22 -0800 Subject: [PATCH 2/7] refactor: migrate Ruby e2e apps from Highlight to LaunchDarkly --- .cursor/rules/commit-message.mdc | 31 +++++ e2e/ruby/rails/api-only/Gemfile | 4 +- .../app/controllers/health_controller.rb | 57 +++++++- .../api-only/config/initializers/highlight.rb | 11 -- .../config/initializers/launchdarkly.rb | 29 ++++ e2e/ruby/rails/api-only/config/routes.rb | 2 + e2e/ruby/rails/demo/Gemfile | 6 +- .../demo/app/controllers/flags_controller.rb | 126 ++++++++++++++++++ .../demo/app/controllers/pages_controller.rb | 20 ++- .../rails/demo/app/views/flags/index.html.erb | 72 ++++++++++ .../rails/demo/app/views/pages/home.html.erb | 41 +++++- .../demo/config/initializers/highlight.rb | 11 -- .../demo/config/initializers/launchdarkly.rb | 30 +++++ e2e/ruby/rails/demo/config/routes.rb | 9 ++ 14 files changed, 419 insertions(+), 30 deletions(-) create mode 100644 .cursor/rules/commit-message.mdc delete mode 100644 e2e/ruby/rails/api-only/config/initializers/highlight.rb create mode 100644 e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb create mode 100644 e2e/ruby/rails/demo/app/controllers/flags_controller.rb create mode 100644 e2e/ruby/rails/demo/app/views/flags/index.html.erb delete mode 100644 e2e/ruby/rails/demo/config/initializers/highlight.rb create mode 100644 e2e/ruby/rails/demo/config/initializers/launchdarkly.rb diff --git a/.cursor/rules/commit-message.mdc b/.cursor/rules/commit-message.mdc new file mode 100644 index 000000000..7af25fce6 --- /dev/null +++ b/.cursor/rules/commit-message.mdc @@ -0,0 +1,31 @@ +--- +description: Commit message rule +alwaysApply: false +--- +# Commit Message Guidelines + +Write commit messages as a single line, max 72 characters. + +## Format +``` +: +``` + +## Types +- `feat` - new feature +- `fix` - bug fix +- `refactor` - code restructuring +- `docs` - documentation +- `test` - tests +- `chore` - maintenance + +## Examples +- `feat: add session replay batching` +- `fix: resolve memory leak in worker pool` +- `refactor: simplify event queue logic` + +## Rules +- Use imperative mood ("add" not "added") +- No period at the end +- Lowercase after the colon +- Be specific but brief diff --git a/e2e/ruby/rails/api-only/Gemfile b/e2e/ruby/rails/api-only/Gemfile index fb57dcf41..c75322a1d 100644 --- a/e2e/ruby/rails/api-only/Gemfile +++ b/e2e/ruby/rails/api-only/Gemfile @@ -46,4 +46,6 @@ group :development do # gem "spring" end -gem 'highlight_io', path: '../../../../sdk/highlight-ruby/highlight' +# LaunchDarkly SDK and Observability Plugin +gem 'launchdarkly-server-sdk', '~> 8.0' +gem 'launchdarkly-observability', path: '../../../../sdk/@launchdarkly/observability-ruby' diff --git a/e2e/ruby/rails/api-only/app/controllers/health_controller.rb b/e2e/ruby/rails/api-only/app/controllers/health_controller.rb index 05fd6a092..977df53e2 100644 --- a/e2e/ruby/rails/api-only/app/controllers/health_controller.rb +++ b/e2e/ruby/rails/api-only/app/controllers/health_controller.rb @@ -1,9 +1,64 @@ class HealthController < ApplicationController def index - render(json: { status: 'ok', timestamp: Time.current }) + context = LaunchDarkly::LDContext.create({ key: 'health-check', kind: 'service' }) + state = $ld_client.all_flags_state(context) + + render(json: { + status: 'ok', + timestamp: Time.current, + launchdarkly: { + connected: state.valid?, + flag_count: state.values_map.size + } + }) end def error raise(StandardError, 'Test API error') end + + # GET /health/flags + # Returns all flag evaluations for testing + def flags + user_key = params[:user_key] || 'anonymous' + context = LaunchDarkly::LDContext.create({ key: user_key, kind: 'user' }) + + state = $ld_client.all_flags_state(context) + + # Get detailed evaluations for all flags + evaluations = {} + state.values_map.each_key do |flag_key| + detail = $ld_client.variation_detail(flag_key, context, nil) + evaluations[flag_key] = { + value: detail.value, + variation_index: detail.variation_index, + reason: detail.reason&.kind + } + end + + render(json: { + valid: state.valid?, + context_key: user_key, + flag_count: evaluations.size, + evaluations: evaluations + }) + end + + # GET /health/flags/:key + # Evaluate a specific flag + def flag + flag_key = params[:key] + user_key = params[:user_key] || 'anonymous' + context = LaunchDarkly::LDContext.create({ key: user_key, kind: 'user' }) + + detail = $ld_client.variation_detail(flag_key, context, nil) + + render(json: { + flag_key: flag_key, + context_key: user_key, + value: detail.value, + variation_index: detail.variation_index, + reason: detail.reason&.to_json + }) + end end diff --git a/e2e/ruby/rails/api-only/config/initializers/highlight.rb b/e2e/ruby/rails/api-only/config/initializers/highlight.rb deleted file mode 100644 index 81a938562..000000000 --- a/e2e/ruby/rails/api-only/config/initializers/highlight.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'highlight' - -Highlight.init('1jdkoe52', environment: Rails.env, otlp_endpoint: 'http://localhost:4318') do |c| - c.service_name = 'highlight-ruby-api-only' - c.service_version = '1.0.0' -end - -highlight_logger = Highlight::Logger.new(nil) -Rails.logger.broadcast_to(highlight_logger) diff --git a/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb new file mode 100644 index 000000000..8e74f8a9d --- /dev/null +++ b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'launchdarkly-server-sdk' +require 'launchdarkly_observability' + +# Create observability plugin +observability_plugin = LaunchDarklyObservability::Plugin.new( + project_id: ENV.fetch('LAUNCHDARKLY_PROJECT_ID', '1jdkoe52'), + otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), + environment: Rails.env, + service_name: 'launchdarkly-ruby-api-only', + service_version: '1.0.0' +) + +# Initialize LaunchDarkly client with real SDK key +sdk_key = ENV.fetch('LAUNCHDARKLY_SDK_KEY') do + Rails.logger.warn '[LaunchDarkly] LAUNCHDARKLY_SDK_KEY not set, client will not connect' + nil +end + +config = LaunchDarkly::Config.new( + plugins: [observability_plugin] +) + +$ld_client = LaunchDarkly::LDClient.new(sdk_key, config) + +at_exit { $ld_client.close } + +Rails.logger.info '[LaunchDarkly] Client initialized with observability plugin' diff --git a/e2e/ruby/rails/api-only/config/routes.rb b/e2e/ruby/rails/api-only/config/routes.rb index 540a0956a..5ba25dcc1 100644 --- a/e2e/ruby/rails/api-only/config/routes.rb +++ b/e2e/ruby/rails/api-only/config/routes.rb @@ -4,5 +4,7 @@ root to: 'health#index' get 'health', to: 'health#index' + get 'health/flags', to: 'health#flags' + get 'health/flags/:key', to: 'health#flag' get 'error', to: 'health#error' end diff --git a/e2e/ruby/rails/demo/Gemfile b/e2e/ruby/rails/demo/Gemfile index eaeb8d967..f08cef14c 100644 --- a/e2e/ruby/rails/demo/Gemfile +++ b/e2e/ruby/rails/demo/Gemfile @@ -72,6 +72,6 @@ group :test do gem 'selenium-webdriver' end -gem 'highlight_io', path: '../../../../sdk/highlight-ruby/highlight' - -gem 'rubocop', '~> 1.65' +# LaunchDarkly SDK and Observability Plugin +gem 'launchdarkly-server-sdk', '~> 8.0' +gem 'launchdarkly-observability', path: '../../../../sdk/@launchdarkly/observability-ruby' diff --git a/e2e/ruby/rails/demo/app/controllers/flags_controller.rb b/e2e/ruby/rails/demo/app/controllers/flags_controller.rb new file mode 100644 index 000000000..124b03902 --- /dev/null +++ b/e2e/ruby/rails/demo/app/controllers/flags_controller.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Controller to demonstrate LaunchDarkly feature flag evaluations +# with observability instrumentation +class FlagsController < ApplicationController + # GET /flags + # Returns all flag evaluations for the current context + def index + context = build_context + + # Get all flags from LaunchDarkly + state = $ld_client.all_flags_state(context, details_only_for_tracked_flags: false) + @all_flags_valid = state.valid? + + # Build evaluations from all available flags + @evaluations = {} + state.values_map.each_key do |flag_key| + @evaluations[flag_key] = $ld_client.variation_detail(flag_key, context, nil) + end + + respond_to do |format| + format.html + format.json { render json: { valid: @all_flags_valid, evaluations: format_evaluations(@evaluations) } } + end + end + + # GET /flags/:key + # Returns a single flag evaluation + def show + context = build_context + flag_key = params[:id] + + detail = $ld_client.variation_detail(flag_key, context, nil) + + respond_to do |format| + format.html { @flag_key = flag_key; @detail = detail } + format.json { render json: format_detail(flag_key, detail) } + end + end + + # POST /flags/evaluate + # Evaluate a flag with a custom context + def evaluate + flag_key = params[:flag_key] + context_data = params[:context] || {} + + context = LaunchDarkly::LDContext.create({ + key: context_data[:key] || 'anonymous', + kind: context_data[:kind] || 'user', + **context_data.except(:key, :kind).to_h.symbolize_keys + }) + + detail = $ld_client.variation_detail(flag_key, context, params[:default]) + + render json: format_detail(flag_key, detail) + end + + # POST /flags/batch + # Evaluate multiple flags at once (demonstrates multiple spans) + def batch + context = build_context + flag_keys = params[:flag_keys] + + unless flag_keys.present? + return render json: { error: 'flag_keys parameter required' }, status: :bad_request + end + + results = flag_keys.each_with_object({}) do |key, hash| + hash[key] = $ld_client.variation(key, context, nil) + end + + render json: { evaluations: results, context_key: context.key } + end + + # GET /flags/all_flags + # Get all flag states (demonstrates all_flags_state method) + def all_flags + context = build_context + state = $ld_client.all_flags_state(context) + + render json: { + valid: state.valid?, + flags: state.to_json + } + end + + private + + def build_context + # Build context from request parameters or session + user_key = params[:user_key] || session.id.to_s.presence || 'anonymous' + user_kind = params[:user_kind] || 'user' + + attrs = { + key: user_key, + kind: user_kind, + anonymous: user_key == 'anonymous' + } + + # Add optional attributes + attrs[:email] = params[:email] if params[:email].present? + attrs[:name] = params[:name] if params[:name].present? + attrs[:plan] = params[:plan] if params[:plan].present? + + LaunchDarkly::LDContext.create(attrs) + end + + def format_evaluations(evaluations) + evaluations.transform_values { |detail| format_detail_hash(detail) } + end + + def format_detail(key, detail) + { + flag_key: key, + **format_detail_hash(detail) + } + end + + def format_detail_hash(detail) + { + value: detail.value, + variation_index: detail.variation_index, + reason: detail.reason&.to_json + } + end +end diff --git a/e2e/ruby/rails/demo/app/controllers/pages_controller.rb b/e2e/ruby/rails/demo/app/controllers/pages_controller.rb index 148b3c49c..6c5aa12e5 100644 --- a/e2e/ruby/rails/demo/app/controllers/pages_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/pages_controller.rb @@ -2,10 +2,28 @@ class PagesController < ApplicationController def home - Highlight.start_span('pages-home-fetch') do + # Create LaunchDarkly context for current user/session + @context = LaunchDarkly::LDContext.create({ + key: session.id.to_s.presence || 'anonymous', + kind: 'user', + anonymous: session.id.blank? + }) + + # Get all available flags (automatically traced by the observability plugin) + state = $ld_client.all_flags_state(@context) + @flags_valid = state.valid? + @flag_count = state.values_map.size + + # Sample a few flag evaluations to demonstrate tracing + @sample_evaluations = state.values_map.first(5).to_h + + # Make an HTTP request (auto-instrumented by OpenTelemetry) + with_launchdarkly_span('pages-home-fetch', attributes: { 'custom.source' => 'demo' }) do uri = URI.parse('http://www.example.com/?test=1') response = Net::HTTP.get_response(uri) @data = response.body end + + Rails.logger.info "[LaunchDarkly] Loaded #{@flag_count} flags, valid=#{@flags_valid}" end end diff --git a/e2e/ruby/rails/demo/app/views/flags/index.html.erb b/e2e/ruby/rails/demo/app/views/flags/index.html.erb new file mode 100644 index 000000000..83df9d690 --- /dev/null +++ b/e2e/ruby/rails/demo/app/views/flags/index.html.erb @@ -0,0 +1,72 @@ +

LaunchDarkly Feature Flags

+ +

+ This page demonstrates the LaunchDarkly Ruby SDK with the observability plugin. + Each flag evaluation is automatically traced via OpenTelemetry. +

+ +

+ Connection Status: <%= @all_flags_valid ? '✓ Connected' : '✗ Not connected' %>
+ Total Flags: <%= @evaluations.size %> +

+ +<% if @evaluations.any? %> +

All Flag Evaluations

+ + + + + + + + + + + + <% @evaluations.each do |key, detail| %> + + + + + + + <% end %> + +
Flag KeyValueVariation IndexReason
<%= key %><%= detail.value.inspect %><%= detail.variation_index %><%= detail.reason&.kind %>
+<% else %> +

No flags available. Make sure LAUNCHDARKLY_SDK_KEY is set correctly.

+<% end %> + +

Test Endpoints

+ + + +

POST Endpoints

+ +
+# Evaluate a specific flag with custom context
+POST /flags/evaluate
+{
+  "flag_key": "your-flag-key",
+  "context": { "key": "user-123", "kind": "user" }
+}
+
+# Batch evaluate multiple flags
+POST /flags/batch
+{
+  "flag_keys": ["flag-1", "flag-2", "flag-3"]
+}
+
+ +

Trace Information

+ +

+ Current Trace ID: + <%= launchdarkly_trace_id || 'N/A' %> +

+ +<%= launchdarkly_traceparent_meta_tag %> diff --git a/e2e/ruby/rails/demo/app/views/pages/home.html.erb b/e2e/ruby/rails/demo/app/views/pages/home.html.erb index c75171007..de9e9c663 100644 --- a/e2e/ruby/rails/demo/app/views/pages/home.html.erb +++ b/e2e/ruby/rails/demo/app/views/pages/home.html.erb @@ -1,6 +1,43 @@ -

Highlight Ruby Demo

+

LaunchDarkly Ruby Observability Demo

-

Controller

+

LaunchDarkly Feature Flags

+

+ Connected to LaunchDarkly. Each flag evaluation creates an OpenTelemetry span. +

+ +

+ Status: <%= @flags_valid ? '✓ Connected' : '✗ Not connected' %>
+ Total flags: <%= @flag_count %>
+ Context key: <%= @context.key %> +

+ +<% if @sample_evaluations.any? %> +

Sample Flag Values

+ + + + + + + + + <% @sample_evaluations.each do |key, value| %> + + + + + <% end %> + +
Flag KeyValue
<%= key %><%= value.inspect %>
+<% else %> +

No flags available. Make sure LAUNCHDARKLY_SDK_KEY is set.

+<% end %> + +

+ View all flags → +

+ +

Controller HTTP Request

<%= @data %>

diff --git a/e2e/ruby/rails/demo/config/initializers/highlight.rb b/e2e/ruby/rails/demo/config/initializers/highlight.rb deleted file mode 100644 index 98b15078a..000000000 --- a/e2e/ruby/rails/demo/config/initializers/highlight.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'highlight' - -Highlight.init('1jdkoe52', environment: Rails.env, otlp_endpoint: 'http://localhost:4318') do |c| - c.service_name = 'highlight-ruby-demo-backend' - c.service_version = '1.0.0' -end - -highlight_logger = Highlight::Logger.new($stdout) -Rails.logger.broadcast_to(highlight_logger) diff --git a/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb new file mode 100644 index 000000000..29291336d --- /dev/null +++ b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'launchdarkly-server-sdk' +require 'launchdarkly_observability' + +# Create observability plugin +observability_plugin = LaunchDarklyObservability::Plugin.new( + project_id: ENV.fetch('LAUNCHDARKLY_PROJECT_ID', '1jdkoe52'), + otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), + environment: Rails.env, + service_name: 'launchdarkly-ruby-demo-backend', + service_version: '1.0.0' +) + +# Initialize LaunchDarkly client with real SDK key +sdk_key = ENV.fetch('LAUNCHDARKLY_SDK_KEY') do + Rails.logger.warn '[LaunchDarkly] LAUNCHDARKLY_SDK_KEY not set, client will not connect' + nil +end + +config = LaunchDarkly::Config.new( + plugins: [observability_plugin] +) + +$ld_client = LaunchDarkly::LDClient.new(sdk_key, config) + +# Ensure clean shutdown on application exit +at_exit { $ld_client.close } + +Rails.logger.info '[LaunchDarkly] Client initialized with observability plugin' diff --git a/e2e/ruby/rails/demo/config/routes.rb b/e2e/ruby/rails/demo/config/routes.rb index 56c26e714..e26b45430 100644 --- a/e2e/ruby/rails/demo/config/routes.rb +++ b/e2e/ruby/rails/demo/config/routes.rb @@ -11,5 +11,14 @@ end resources :errors, only: [:create] + # LaunchDarkly feature flag routes + resources :flags, only: %i[index show] do + collection do + post :evaluate + post :batch + get :all_flags + end + end + root to: 'pages#home' end From d973e8b6c79dce9c8f3ac394ae5f1476536bab89 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 10:03:09 -0800 Subject: [PATCH 3/7] Make SDK key and environment optional --- .../config/initializers/launchdarkly.rb | 4 +- e2e/ruby/rails/demo/Gemfile.lock | 451 ++++++++---------- .../demo/config/initializers/launchdarkly.rb | 6 +- .../observability-ruby/README.md | 41 +- .../lib/launchdarkly_observability.rb | 9 +- .../opentelemetry_config.rb | 13 +- .../lib/launchdarkly_observability/plugin.rb | 33 +- 7 files changed, 267 insertions(+), 290 deletions(-) diff --git a/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb index 8e74f8a9d..d59823f6f 100644 --- a/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb +++ b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb @@ -3,11 +3,9 @@ require 'launchdarkly-server-sdk' require 'launchdarkly_observability' -# Create observability plugin +# Create observability plugin (SDK key and environment automatically inferred) observability_plugin = LaunchDarklyObservability::Plugin.new( - project_id: ENV.fetch('LAUNCHDARKLY_PROJECT_ID', '1jdkoe52'), otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), - environment: Rails.env, service_name: 'launchdarkly-ruby-api-only', service_version: '1.0.0' ) diff --git a/e2e/ruby/rails/demo/Gemfile.lock b/e2e/ruby/rails/demo/Gemfile.lock index 2fe36e32f..b73b9a5e5 100644 --- a/e2e/ruby/rails/demo/Gemfile.lock +++ b/e2e/ruby/rails/demo/Gemfile.lock @@ -1,12 +1,12 @@ PATH - remote: ../../../../sdk/highlight-ruby/highlight + remote: ../../../../sdk/@launchdarkly/observability-ruby specs: - highlight_io (0.5.5) - grpc (~> 1.66) - opentelemetry-exporter-otlp (~> 0.28.1) - opentelemetry-instrumentation-all (~> 0.62.1) - opentelemetry-sdk (~> 1.5.0) - opentelemetry-semantic_conventions (~> 1.10.1) + launchdarkly-observability (0.1.0) + launchdarkly-server-sdk (>= 8.0) + opentelemetry-exporter-otlp (~> 0.28) + opentelemetry-instrumentation-all (~> 0.62) + opentelemetry-sdk (~> 1.4) + opentelemetry-semantic_conventions (~> 1.10) GEM remote: https://rubygems.org/ @@ -90,7 +90,6 @@ GEM tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - ast (2.4.3) base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) @@ -114,48 +113,48 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + domain_name (0.6.20240107) drb (2.2.1) erubi (1.13.1) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86-linux-gnu) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (4.30.1) + google-protobuf (4.33.4) bigdecimal rake (>= 13) - google-protobuf (4.30.1-aarch64-linux) + google-protobuf (4.33.4-aarch64-linux-gnu) bigdecimal rake (>= 13) - google-protobuf (4.30.1-arm64-darwin) + google-protobuf (4.33.4-arm64-darwin) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86-linux) + google-protobuf (4.33.4-x86-linux-gnu) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86_64-darwin) + google-protobuf (4.33.4-x86_64-darwin) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86_64-linux) + google-protobuf (4.33.4-x86_64-linux-gnu) bigdecimal rake (>= 13) - googleapis-common-protos-types (1.19.0) - google-protobuf (>= 3.18, < 5.a) - grpc (1.71.0) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-aarch64-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-arm64-darwin) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86_64-darwin) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86_64-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) + googleapis-common-protos-types (1.22.0) + google-protobuf (~> 4.26) + http (5.3.1) + addressable (~> 2.8) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.1.0) + domain_name (~> 0.5) + http-form_data (2.3.0) i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) @@ -170,9 +169,23 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.10.2) - language_server-protocol (3.17.0.4) - lint_roller (1.1.0) + json (2.18.1) + launchdarkly-server-sdk (8.12.0) + benchmark (~> 0.1, >= 0.1.1) + concurrent-ruby (~> 1.1) + http (>= 4.4.0, < 6.0.0) + json (~> 2.3) + ld-eventsource (= 2.5.0) + observer (~> 0.1.2) + openssl (>= 3.1.2, < 5.0) + semantic (~> 1.6) + zlib (~> 3.1) + ld-eventsource (2.5.0) + concurrent-ruby (~> 1.0) + http (>= 4.4.1, < 6.0.0) + llhttp-ffi (0.5.1) + ffi-compiler (~> 1.0) + rake (~> 13.0) logger (1.6.6) loofah (2.24.0) crass (~> 1.0.2) @@ -212,211 +225,181 @@ GEM racc (~> 1.4) nokogiri (1.18.5-x86_64-linux-gnu) racc (~> 1.4) - opentelemetry-api (1.5.0) - opentelemetry-common (0.22.0) + observer (0.1.2) + openssl (4.0.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.28.1) + opentelemetry-exporter-otlp (0.31.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) - opentelemetry-sdk (~> 1.2) + opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-helpers-mysql (0.2.0) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-mysql (0.4.0) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) - opentelemetry-helpers-sql-obfuscation (0.3.0) - opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.1.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-action_pack (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.6) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (>= 0.6.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.7.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_support (0.6.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-all (0.62.1) - opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) - opentelemetry-instrumentation-aws_lambda (~> 0.1.0) - opentelemetry-instrumentation-aws_sdk (~> 0.5.0) - opentelemetry-instrumentation-bunny (~> 0.21.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.21.1) - opentelemetry-instrumentation-dalli (~> 0.25.0) - opentelemetry-instrumentation-delayed_job (~> 0.22.0) - opentelemetry-instrumentation-ethon (~> 0.21.1) - opentelemetry-instrumentation-excon (~> 0.22.0) - opentelemetry-instrumentation-faraday (~> 0.24.0) - opentelemetry-instrumentation-grape (~> 0.2.0) - opentelemetry-instrumentation-graphql (~> 0.28.0) - opentelemetry-instrumentation-gruf (~> 0.2.0) - opentelemetry-instrumentation-http (~> 0.23.1) - opentelemetry-instrumentation-http_client (~> 0.22.1) - opentelemetry-instrumentation-koala (~> 0.20.1) - opentelemetry-instrumentation-lmdb (~> 0.22.1) - opentelemetry-instrumentation-mongo (~> 0.22.1) - opentelemetry-instrumentation-mysql2 (~> 0.27.0) - opentelemetry-instrumentation-net_http (~> 0.22.1) - opentelemetry-instrumentation-pg (~> 0.27.0) - opentelemetry-instrumentation-que (~> 0.8.0) - opentelemetry-instrumentation-racecar (~> 0.3.0) - opentelemetry-instrumentation-rack (~> 0.24.0) - opentelemetry-instrumentation-rails (~> 0.31.0) - opentelemetry-instrumentation-rake (~> 0.2.1) - opentelemetry-instrumentation-rdkafka (~> 0.4.0) - opentelemetry-instrumentation-redis (~> 0.25.1) - opentelemetry-instrumentation-resque (~> 0.5.0) - opentelemetry-instrumentation-restclient (~> 0.22.1) - opentelemetry-instrumentation-ruby_kafka (~> 0.21.0) - opentelemetry-instrumentation-sidekiq (~> 0.25.0) - opentelemetry-instrumentation-sinatra (~> 0.24.0) - opentelemetry-instrumentation-trilogy (~> 0.59.0) - opentelemetry-instrumentation-aws_lambda (0.1.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-aws_sdk (0.5.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-base (0.22.6) + opentelemetry-helpers-sql (0.3.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.4.0) opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.2) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-active_record (0.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-all (0.90.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-anthropic (~> 0.3.0) + opentelemetry-instrumentation-aws_lambda (~> 0.6.0) + opentelemetry-instrumentation-aws_sdk (~> 0.11.0) + opentelemetry-instrumentation-bunny (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-dalli (~> 0.29.0) + opentelemetry-instrumentation-delayed_job (~> 0.25.1) + opentelemetry-instrumentation-ethon (~> 0.27.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-grape (~> 0.5.0) + opentelemetry-instrumentation-graphql (~> 0.31.1) + opentelemetry-instrumentation-grpc (~> 0.4.1) + opentelemetry-instrumentation-gruf (~> 0.5.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-httpx (~> 0.6.0) + opentelemetry-instrumentation-koala (~> 0.23.0) + opentelemetry-instrumentation-lmdb (~> 0.25.0) + opentelemetry-instrumentation-mongo (~> 0.25.0) + opentelemetry-instrumentation-mysql2 (~> 0.33.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) + opentelemetry-instrumentation-que (~> 0.12.0) + opentelemetry-instrumentation-racecar (~> 0.6.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.1) + opentelemetry-instrumentation-rake (~> 0.5.0) + opentelemetry-instrumentation-rdkafka (~> 0.9.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-resque (~> 0.8.0) + opentelemetry-instrumentation-restclient (~> 0.26.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.24.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.1) + opentelemetry-instrumentation-sinatra (~> 0.28.0) + opentelemetry-instrumentation-trilogy (~> 0.66.0) + opentelemetry-instrumentation-anthropic (0.3.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_lambda (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_sdk (0.11.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-bunny (0.21.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-concurrent_ruby (0.21.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-dalli (0.25.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-delayed_job (0.22.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-ethon (0.21.9) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.5) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-grape (0.2.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-graphql (0.28.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-gruf (0.2.1) - opentelemetry-api (>= 1.0.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.5) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-koala (0.20.6) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-lmdb (0.22.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-mongo (0.22.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-mysql2 (0.27.2) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-bunny (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-concurrent_ruby (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-dalli (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-delayed_job (0.25.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ethon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grape (0.5.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-graphql (0.31.2) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grpc (0.4.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-gruf (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-httpx (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-koala (0.23.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-lmdb (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mongo (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mysql2 (0.33.0) opentelemetry-helpers-mysql - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.27.4) - opentelemetry-api (~> 1.0) - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-que (0.8.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-racecar (0.3.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.24.6) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.31.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.1.0) - opentelemetry-instrumentation-action_pack (~> 0.9.0) - opentelemetry-instrumentation-action_view (~> 0.7.0) - opentelemetry-instrumentation-active_job (~> 0.7.0) - opentelemetry-instrumentation-active_record (~> 0.7.0) - opentelemetry-instrumentation-active_support (~> 0.6.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rake (0.2.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rdkafka (0.4.9) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-redis (0.25.7) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-resque (0.5.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-restclient (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-ruby_kafka (0.21.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sidekiq (0.25.7) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sinatra (0.24.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-trilogy (0.59.3) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.35.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-que (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-racecar (0.6.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-rake (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rdkafka (0.9.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-resque (0.8.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-restclient (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ruby_kafka (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sinatra (0.28.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-trilogy (0.66.0) opentelemetry-helpers-mysql - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) opentelemetry-semantic_conventions (>= 1.8.0) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.5.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.1) + opentelemetry-semantic_conventions (1.36.0) opentelemetry-api (~> 1.0) - parallel (1.26.3) - parser (3.3.7.2) - ast (~> 2.4.1) - racc pp (0.6.2) prettyprint prettyprint (0.2.0) @@ -464,7 +447,6 @@ GEM rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) - rainbow (3.1.1) rake (13.2.1) rdoc (6.12.0) psych (>= 4.0.0) @@ -473,20 +455,6 @@ GEM reline (0.6.0) io-console (~> 0.5) rexml (3.4.1) - rubocop (1.74.0) - json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.41.0) - parser (>= 3.3.7.2) - ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.29.1) @@ -495,6 +463,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + semantic (1.6.1) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -518,9 +487,6 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -534,6 +500,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.2) + zlib (3.2.2) PLATFORMS aarch64-linux @@ -547,13 +514,13 @@ DEPENDENCIES bootsnap capybara debug - highlight_io! importmap-rails jbuilder + launchdarkly-observability! + launchdarkly-server-sdk (~> 8.0) puma (~> 6.0) rails (~> 7.1.0) redis (~> 4.0) - rubocop (~> 1.65) selenium-webdriver sprockets-rails sqlite3 (~> 1.4) diff --git a/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb index 29291336d..9df2c81d8 100644 --- a/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb +++ b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb @@ -3,11 +3,9 @@ require 'launchdarkly-server-sdk' require 'launchdarkly_observability' -# Create observability plugin +# Create observability plugin (SDK key and environment automatically inferred) observability_plugin = LaunchDarklyObservability::Plugin.new( - project_id: ENV.fetch('LAUNCHDARKLY_PROJECT_ID', '1jdkoe52'), - otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), - environment: Rails.env, + otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', LaunchDarklyObservability::DEFAULT_ENDPOINT), service_name: 'launchdarkly-ruby-demo-backend', service_version: '1.0.0' ) diff --git a/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md index 6e89857e7..74db23ee7 100644 --- a/sdk/@launchdarkly/observability-ruby/README.md +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -52,11 +52,8 @@ For logs and metrics support (optional): require 'launchdarkly-server-sdk' require 'launchdarkly_observability' -# Create observability plugin -observability = LaunchDarklyObservability::Plugin.new( - project_id: 'your-launchdarkly-project-id', - environment: 'production' -) +# Create observability plugin (SDK key and environment automatically inferred) +observability = LaunchDarklyObservability::Plugin.new # Initialize LaunchDarkly client with plugin config = LaunchDarkly::Config.new(plugins: [observability]) @@ -67,6 +64,8 @@ context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) value = client.variation('my-feature-flag', context, false) ``` +> **Note**: The plugin automatically extracts the SDK key from the LaunchDarkly client during initialization. The backend derives both the project and environment from the SDK key for telemetry routing, so you don't need to configure them explicitly. + ### Rails Usage Create an initializer at `config/initializers/launchdarkly.rb`: @@ -75,10 +74,8 @@ Create an initializer at `config/initializers/launchdarkly.rb`: require 'launchdarkly-server-sdk' require 'launchdarkly_observability' -# Setup observability plugin +# Setup observability plugin (SDK key and environment automatically inferred) observability = LaunchDarklyObservability::Plugin.new( - project_id: ENV['LAUNCHDARKLY_PROJECT_ID'], - environment: Rails.env, service_name: 'my-rails-app', service_version: '1.0.0' ) @@ -121,14 +118,14 @@ end ```ruby LaunchDarklyObservability::Plugin.new( - # Required: LaunchDarkly project ID for telemetry routing - project_id: 'your-project-id', + # All parameters are optional - SDK key and environment are automatically inferred # Optional: Custom OTLP endpoint (default: LaunchDarkly's endpoint) otlp_endpoint: 'https://otel.observability.app.launchdarkly.com:4318', - # Optional: Deployment environment name - environment: 'production', + # Optional: Environment override (default: inferred from SDK key) + # Only specify for advanced scenarios like deployment-specific suffixes + environment: 'production-canary', # Optional: Service identification service_name: 'my-service', @@ -147,15 +144,19 @@ LaunchDarklyObservability::Plugin.new( ) ``` +> **Advanced**: You can explicitly pass `sdk_key` or `project_id` for testing scenarios, but this is rarely needed since they're automatically extracted from the client. + ### Environment Variables -You can also configure via environment variables: +You can configure via environment variables: | Variable | Description | |----------|-------------| -| `LAUNCHDARKLY_PROJECT_ID` | LaunchDarkly project ID | +| `LAUNCHDARKLY_SDK_KEY` | LaunchDarkly SDK key (automatically extracted from client during initialization) | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Custom OTLP endpoint | -| `OTEL_SERVICE_NAME` | Service name | +| `OTEL_SERVICE_NAME` | Service name (if not specified in plugin options) | + +> **Note**: The environment associated with your SDK key is automatically determined by the backend, so you don't need to configure it separately. ## Telemetry Details @@ -251,7 +252,6 @@ By default, the plugin enables OpenTelemetry auto-instrumentation for common Rub ```ruby LaunchDarklyObservability::Plugin.new( - project_id: 'my-project', instrumentations: { # Disable specific instrumentations 'OpenTelemetry::Instrumentation::Redis' => { enabled: false }, @@ -366,7 +366,7 @@ end ```ruby # Initialize the plugin (alternative to creating Plugin directly) -LaunchDarklyObservability.init(project_id: 'my-project', environment: 'prod') +LaunchDarklyObservability.init # Check if initialized LaunchDarklyObservability.initialized? # => true @@ -381,11 +381,12 @@ LaunchDarklyObservability.shutdown ### Plugin Class ```ruby -plugin = LaunchDarklyObservability::Plugin.new(project_id: 'my-project') +# SDK key and environment are automatically inferred +plugin = LaunchDarklyObservability::Plugin.new(service_name: 'my-service') -plugin.project_id # => 'my-project' +plugin.project_id # => nil (extracted from client during registration) plugin.otlp_endpoint # => 'https://otel...' -plugin.environment # => '' +plugin.environment # => nil (inferred from SDK key by backend) plugin.registered? # => false (true after client initialization) plugin.flush # Flush pending data plugin.shutdown # Stop the plugin diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb index c6372707c..5c5b0e5c7 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb @@ -36,16 +36,17 @@ class << self # Initialize the observability plugin # - # @param project_id [String] LaunchDarkly project ID (required) + # @param project_id [String, nil] LaunchDarkly project ID (optional - SDK key will be extracted from client if not provided) + # @param sdk_key [String, nil] LaunchDarkly SDK key (optional - will be extracted from client if not provided) # @param options [Hash] Additional configuration options # @option options [String] :otlp_endpoint Custom OTLP endpoint URL - # @option options [String] :environment Deployment environment name + # @option options [String] :environment Deployment environment (optional - inferred from SDK key by default) # @option options [String] :service_name Service name for traces # @option options [String] :service_version Service version # @option options [Hash] :instrumentations Configuration for auto-instrumentations # @return [Plugin] The initialized plugin - def init(project_id:, **options) - @instance = Plugin.new(project_id: project_id, **options) + def init(project_id: nil, sdk_key: nil, **options) + @instance = Plugin.new(project_id: project_id, sdk_key: sdk_key, **options) end # Check if the plugin has been initialized diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb index 7545a6833..d82f9ae4f 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/opentelemetry_config.rb @@ -37,10 +37,10 @@ class OpenTelemetryConfig # # @param project_id [String] LaunchDarkly project ID # @param otlp_endpoint [String] OTLP collector endpoint - # @param environment [String] Deployment environment name + # @param environment [String, nil] Deployment environment name (optional - inferred from SDK key if not provided) # @param sdk_metadata [LaunchDarkly::Interfaces::Plugins::SdkMetadata, nil] # @param options [Hash] Additional options - def initialize(project_id:, otlp_endpoint:, environment:, sdk_metadata: nil, **options) + def initialize(project_id:, otlp_endpoint:, environment: nil, sdk_metadata: nil, **options) @project_id = project_id @otlp_endpoint = otlp_endpoint @environment = environment @@ -178,9 +178,14 @@ def create_resource SDK_VERSION_ATTRIBUTE => OpenTelemetry::SDK::VERSION, SDK_LANGUAGE_ATTRIBUTE => 'ruby', DISTRO_NAME_ATTRIBUTE => 'launchdarkly-observability-ruby', - DISTRO_VERSION_ATTRIBUTE => LaunchDarklyObservability::VERSION, - OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => @environment + DISTRO_VERSION_ATTRIBUTE => LaunchDarklyObservability::VERSION } + + # Only set deployment.environment if explicitly provided + # Otherwise, backend infers it from the SDK key + if @environment && !@environment.empty? + attrs[OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT] = @environment + end # Add service name service_name = @options[:service_name] || infer_service_name diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb index beb4ed6a8..360a2a739 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/plugin.rb @@ -8,14 +8,10 @@ module LaunchDarklyObservability # This plugin integrates with the LaunchDarkly Ruby SDK to automatically # instrument flag evaluations with OpenTelemetry traces, logs, and metrics. # - # @example Basic usage - # plugin = LaunchDarklyObservability::Plugin.new( - # project_id: 'my-project-id', - # environment: 'production' - # ) - # + # @example Basic usage (SDK key and environment automatically extracted) + # plugin = LaunchDarklyObservability::Plugin.new # config = LaunchDarkly::Config.new(plugins: [plugin]) - # client = LaunchDarkly::LDClient.new('sdk-key', config) + # client = LaunchDarkly::LDClient.new(ENV['LAUNCHDARKLY_SDK_KEY'], config) # class Plugin include LaunchDarkly::Interfaces::Plugins::Plugin @@ -34,9 +30,13 @@ class Plugin # Initialize a new observability plugin # - # @param project_id [String] LaunchDarkly project ID (required for routing telemetry) + # @param project_id [String, nil] LaunchDarkly project ID for routing telemetry. + # If not provided, the SDK key from the client will be used automatically. + # @param sdk_key [String, nil] LaunchDarkly SDK key (optional - will be extracted from client if not provided). + # The backend will derive the project and environment from the SDK key. # @param otlp_endpoint [String] OTLP collector endpoint (default: LaunchDarkly's endpoint) - # @param environment [String] Deployment environment name (e.g., 'production', 'staging') + # @param environment [String, nil] Deployment environment name (optional - inferred from SDK key by default). + # Only specify this for advanced scenarios like deployment-specific suffixes (e.g., 'production-canary'). # @param options [Hash] Additional configuration options # @option options [String] :service_name Service name for resource attributes # @option options [String] :service_version Service version for resource attributes @@ -44,10 +44,10 @@ class Plugin # @option options [Boolean] :enable_traces Enable trace instrumentation (default: true) # @option options [Boolean] :enable_logs Enable log instrumentation (default: true) # @option options [Boolean] :enable_metrics Enable metrics instrumentation (default: true) - def initialize(project_id:, otlp_endpoint: DEFAULT_ENDPOINT, environment: '', **options) - @project_id = project_id + def initialize(project_id: nil, sdk_key: nil, otlp_endpoint: DEFAULT_ENDPOINT, environment: nil, **options) + @project_id = project_id || sdk_key @otlp_endpoint = otlp_endpoint - @environment = environment.to_s + @environment = environment # Keep nil if not provided - backend infers from SDK key @options = default_options.merge(options) @hook = Hook.new @otel_config = nil @@ -79,8 +79,15 @@ def get_hooks(_environment_metadata) def register(_client, environment_metadata) return if @registered + # Use provided project_id, or extract SDK key from the client + project_id = @project_id || environment_metadata&.sdk_key + + if project_id.nil? || project_id.empty? + raise ArgumentError, 'Unable to determine project_id: no project_id or sdk_key provided, and client SDK key is unavailable' + end + @otel_config = OpenTelemetryConfig.new( - project_id: @project_id, + project_id: project_id, otlp_endpoint: @otlp_endpoint, environment: @environment, sdk_metadata: environment_metadata&.sdk, From fc350f8fcb20acc9cc4a896d953b878c5390c5fd Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 12:27:40 -0800 Subject: [PATCH 4/7] Add manual instrumentation helpers --- e2e/ruby/rails/api-only/Gemfile.lock | 427 +++++++++--------- .../controllers/health_controller_test.rb | 4 +- .../app/controllers/application_controller.rb | 7 + .../demo/app/controllers/errors_controller.rb | 16 +- .../demo/app/controllers/flags_controller.rb | 12 +- .../demo/app/controllers/logs_controller.rb | 5 +- .../demo/app/controllers/pages_controller.rb | 2 +- .../demo/app/controllers/traces_controller.rb | 15 +- .../rails/demo/app/views/flags/show.html.erb | 36 ++ .../app/views/layouts/application.html.erb | 21 +- .../demo/config/initializers/launchdarkly.rb | 14 +- e2e/ruby/rails/demo/config/puma.rb | 6 + e2e/ruby/sinatra/ruby2-logging/.ruby-version | 2 +- e2e/ruby/sinatra/ruby2-logging/.tool-versions | 2 +- e2e/ruby/sinatra/ruby2-logging/Gemfile | 5 +- e2e/ruby/sinatra/ruby2-logging/Gemfile.lock | 408 +++++++++-------- e2e/ruby/sinatra/ruby2-logging/app.rb | 71 ++- .../observability-ruby/.ruby-version | 1 + .../MANUAL_INSTRUMENTATION.md | 259 +++++++++++ .../observability-ruby/README.md | 146 +++++- .../lib/launchdarkly_observability.rb | 68 +++ .../test/module_methods_test.rb | 139 ++++++ 22 files changed, 1168 insertions(+), 498 deletions(-) create mode 100644 e2e/ruby/rails/demo/app/views/flags/show.html.erb create mode 100644 sdk/@launchdarkly/observability-ruby/.ruby-version create mode 100644 sdk/@launchdarkly/observability-ruby/MANUAL_INSTRUMENTATION.md create mode 100644 sdk/@launchdarkly/observability-ruby/test/module_methods_test.rb diff --git a/e2e/ruby/rails/api-only/Gemfile.lock b/e2e/ruby/rails/api-only/Gemfile.lock index 4a0791113..47f303c9c 100644 --- a/e2e/ruby/rails/api-only/Gemfile.lock +++ b/e2e/ruby/rails/api-only/Gemfile.lock @@ -1,12 +1,12 @@ PATH - remote: ../../../../sdk/highlight-ruby/highlight + remote: ../../../../sdk/@launchdarkly/observability-ruby specs: - highlight_io (0.5.5) - grpc (~> 1.66) - opentelemetry-exporter-otlp (~> 0.28.1) - opentelemetry-instrumentation-all (~> 0.62.1) - opentelemetry-sdk (~> 1.5.0) - opentelemetry-semantic_conventions (~> 1.10.1) + launchdarkly-observability (0.1.0) + launchdarkly-server-sdk (>= 8.0) + opentelemetry-exporter-otlp (~> 0.28) + opentelemetry-instrumentation-all (~> 0.62) + opentelemetry-sdk (~> 1.4) + opentelemetry-semantic_conventions (~> 1.10) GEM remote: https://rubygems.org/ @@ -88,6 +88,8 @@ GEM mutex_m securerandom (>= 0.3) tzinfo (~> 2.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) @@ -101,48 +103,48 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + domain_name (0.6.20240107) drb (2.2.1) erubi (1.13.1) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86-linux-gnu) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (4.30.1) + google-protobuf (4.33.4) bigdecimal rake (>= 13) - google-protobuf (4.30.1-aarch64-linux) + google-protobuf (4.33.4-aarch64-linux-gnu) bigdecimal rake (>= 13) - google-protobuf (4.30.1-arm64-darwin) + google-protobuf (4.33.4-arm64-darwin) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86-linux) + google-protobuf (4.33.4-x86-linux-gnu) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86_64-darwin) + google-protobuf (4.33.4-x86_64-darwin) bigdecimal rake (>= 13) - google-protobuf (4.30.1-x86_64-linux) + google-protobuf (4.33.4-x86_64-linux-gnu) bigdecimal rake (>= 13) - googleapis-common-protos-types (1.19.0) - google-protobuf (>= 3.18, < 5.a) - grpc (1.71.0) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-aarch64-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-arm64-darwin) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86_64-darwin) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - grpc (1.71.0-x86_64-linux) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) + googleapis-common-protos-types (1.22.0) + google-protobuf (~> 4.26) + http (5.3.1) + addressable (~> 2.8) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.1.0) + domain_name (~> 0.5) + http-form_data (2.3.0) i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.0) @@ -150,6 +152,23 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.18.1) + launchdarkly-server-sdk (8.12.0) + benchmark (~> 0.1, >= 0.1.1) + concurrent-ruby (~> 1.1) + http (>= 4.4.0, < 6.0.0) + json (~> 2.3) + ld-eventsource (= 2.5.0) + observer (~> 0.1.2) + openssl (>= 3.1.2, < 5.0) + semantic (~> 1.6) + zlib (~> 3.1) + ld-eventsource (2.5.0) + concurrent-ruby (~> 1.0) + http (>= 4.4.1, < 6.0.0) + llhttp-ffi (0.5.1) + ffi-compiler (~> 1.0) + rake (~> 13.0) logger (1.6.6) loofah (2.24.0) crass (~> 1.0.2) @@ -188,206 +207,180 @@ GEM racc (~> 1.4) nokogiri (1.18.5-x86_64-linux-gnu) racc (~> 1.4) - opentelemetry-api (1.5.0) - opentelemetry-common (0.22.0) + observer (0.1.2) + openssl (4.0.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.28.1) + opentelemetry-exporter-otlp (0.31.1) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) - opentelemetry-sdk (~> 1.2) + opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-helpers-mysql (0.2.0) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.21) - opentelemetry-helpers-sql-obfuscation (0.3.0) + opentelemetry-helpers-mysql (0.4.0) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.1.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-action_pack (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.7.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.6) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_job (0.7.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_model_serializers (0.20.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (>= 0.6.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.7.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_support (0.6.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-all (0.62.1) - opentelemetry-instrumentation-active_model_serializers (~> 0.20.1) - opentelemetry-instrumentation-aws_lambda (~> 0.1.0) - opentelemetry-instrumentation-aws_sdk (~> 0.5.0) - opentelemetry-instrumentation-bunny (~> 0.21.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.21.1) - opentelemetry-instrumentation-dalli (~> 0.25.0) - opentelemetry-instrumentation-delayed_job (~> 0.22.0) - opentelemetry-instrumentation-ethon (~> 0.21.1) - opentelemetry-instrumentation-excon (~> 0.22.0) - opentelemetry-instrumentation-faraday (~> 0.24.0) - opentelemetry-instrumentation-grape (~> 0.2.0) - opentelemetry-instrumentation-graphql (~> 0.28.0) - opentelemetry-instrumentation-gruf (~> 0.2.0) - opentelemetry-instrumentation-http (~> 0.23.1) - opentelemetry-instrumentation-http_client (~> 0.22.1) - opentelemetry-instrumentation-koala (~> 0.20.1) - opentelemetry-instrumentation-lmdb (~> 0.22.1) - opentelemetry-instrumentation-mongo (~> 0.22.1) - opentelemetry-instrumentation-mysql2 (~> 0.27.0) - opentelemetry-instrumentation-net_http (~> 0.22.1) - opentelemetry-instrumentation-pg (~> 0.27.0) - opentelemetry-instrumentation-que (~> 0.8.0) - opentelemetry-instrumentation-racecar (~> 0.3.0) - opentelemetry-instrumentation-rack (~> 0.24.0) - opentelemetry-instrumentation-rails (~> 0.31.0) - opentelemetry-instrumentation-rake (~> 0.2.1) - opentelemetry-instrumentation-rdkafka (~> 0.4.0) - opentelemetry-instrumentation-redis (~> 0.25.1) - opentelemetry-instrumentation-resque (~> 0.5.0) - opentelemetry-instrumentation-restclient (~> 0.22.1) - opentelemetry-instrumentation-ruby_kafka (~> 0.21.0) - opentelemetry-instrumentation-sidekiq (~> 0.25.0) - opentelemetry-instrumentation-sinatra (~> 0.24.0) - opentelemetry-instrumentation-trilogy (~> 0.59.0) - opentelemetry-instrumentation-aws_lambda (0.1.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-aws_sdk (0.5.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-base (0.22.6) + opentelemetry-helpers-sql (0.3.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.4.0) opentelemetry-api (~> 1.0) opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.2) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-active_record (0.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-all (0.90.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-anthropic (~> 0.3.0) + opentelemetry-instrumentation-aws_lambda (~> 0.6.0) + opentelemetry-instrumentation-aws_sdk (~> 0.11.0) + opentelemetry-instrumentation-bunny (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-dalli (~> 0.29.0) + opentelemetry-instrumentation-delayed_job (~> 0.25.1) + opentelemetry-instrumentation-ethon (~> 0.27.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-grape (~> 0.5.0) + opentelemetry-instrumentation-graphql (~> 0.31.1) + opentelemetry-instrumentation-grpc (~> 0.4.1) + opentelemetry-instrumentation-gruf (~> 0.5.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-httpx (~> 0.6.0) + opentelemetry-instrumentation-koala (~> 0.23.0) + opentelemetry-instrumentation-lmdb (~> 0.25.0) + opentelemetry-instrumentation-mongo (~> 0.25.0) + opentelemetry-instrumentation-mysql2 (~> 0.33.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) + opentelemetry-instrumentation-que (~> 0.12.0) + opentelemetry-instrumentation-racecar (~> 0.6.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.1) + opentelemetry-instrumentation-rake (~> 0.5.0) + opentelemetry-instrumentation-rdkafka (~> 0.9.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-resque (~> 0.8.0) + opentelemetry-instrumentation-restclient (~> 0.26.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.24.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.1) + opentelemetry-instrumentation-sinatra (~> 0.28.0) + opentelemetry-instrumentation-trilogy (~> 0.66.0) + opentelemetry-instrumentation-anthropic (0.3.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_lambda (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_sdk (0.11.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-bunny (0.21.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-concurrent_ruby (0.21.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-dalli (0.25.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-delayed_job (0.22.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-ethon (0.21.9) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.5) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-grape (0.2.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-graphql (0.28.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-gruf (0.2.1) - opentelemetry-api (>= 1.0.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.5) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-koala (0.20.6) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-lmdb (0.22.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-mongo (0.22.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-mysql2 (0.27.2) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-bunny (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-concurrent_ruby (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-dalli (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-delayed_job (0.25.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ethon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grape (0.5.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-graphql (0.31.2) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grpc (0.4.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-gruf (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-httpx (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-koala (0.23.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-lmdb (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mongo (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mysql2 (0.33.0) opentelemetry-helpers-mysql - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.27.4) - opentelemetry-api (~> 1.0) - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-que (0.8.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-racecar (0.3.4) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (0.24.6) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.31.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.1.0) - opentelemetry-instrumentation-action_pack (~> 0.9.0) - opentelemetry-instrumentation-action_view (~> 0.7.0) - opentelemetry-instrumentation-active_job (~> 0.7.0) - opentelemetry-instrumentation-active_record (~> 0.7.0) - opentelemetry-instrumentation-active_support (~> 0.6.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rake (0.2.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rdkafka (0.4.9) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-redis (0.25.7) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-resque (0.5.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-restclient (0.22.8) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-ruby_kafka (0.21.3) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sidekiq (0.25.7) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-sinatra (0.24.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-trilogy (0.59.3) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.35.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-que (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-racecar (0.6.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-rake (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rdkafka (0.9.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-resque (0.8.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-restclient (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ruby_kafka (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sinatra (0.28.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-trilogy (0.66.0) opentelemetry-helpers-mysql - opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.22.1) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) opentelemetry-semantic_conventions (>= 1.8.0) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.5.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.10.1) + opentelemetry-semantic_conventions (1.36.0) opentelemetry-api (~> 1.0) pp (0.6.2) prettyprint @@ -395,6 +388,7 @@ GEM psych (5.2.3) date stringio + public_suffix (7.0.2) puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) @@ -441,6 +435,7 @@ GEM reline (0.6.0) io-console (~> 0.5) securerandom (0.4.1) + semantic (1.6.1) sqlite3 (1.7.3-aarch64-linux) sqlite3 (1.7.3-arm-linux) sqlite3 (1.7.3-arm64-darwin) @@ -457,6 +452,7 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) zeitwerk (2.7.2) + zlib (3.2.2) PLATFORMS aarch64-linux @@ -469,7 +465,8 @@ PLATFORMS DEPENDENCIES bootsnap debug - highlight_io! + launchdarkly-observability! + launchdarkly-server-sdk (~> 8.0) puma (~> 6.0) rails (~> 7.1.0) sqlite3 (~> 1.4) diff --git a/e2e/ruby/rails/api-only/test/controllers/health_controller_test.rb b/e2e/ruby/rails/api-only/test/controllers/health_controller_test.rb index 67c767e3a..bc3dbe96d 100644 --- a/e2e/ruby/rails/api-only/test/controllers/health_controller_test.rb +++ b/e2e/ruby/rails/api-only/test/controllers/health_controller_test.rb @@ -17,8 +17,8 @@ class HealthControllerTest < ActionDispatch::IntegrationTest end end - test 'should include highlight headers' do - get health_path, headers: { 'X-Highlight-Request' => 'test-session/test-request' } + test 'should handle custom headers' do + get health_path, headers: { 'X-Request-ID' => 'test-request-123' } assert_response :success end end diff --git a/e2e/ruby/rails/demo/app/controllers/application_controller.rb b/e2e/ruby/rails/demo/app/controllers/application_controller.rb index 7944f9f99..4655b2ba8 100644 --- a/e2e/ruby/rails/demo/app/controllers/application_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/application_controller.rb @@ -1,4 +1,11 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base + private + + # Helper method to access LaunchDarkly client + def ld_client + Rails.configuration.ld_client + end + helper_method :ld_client end diff --git a/e2e/ruby/rails/demo/app/controllers/errors_controller.rb b/e2e/ruby/rails/demo/app/controllers/errors_controller.rb index 72ff94028..091e26cb0 100644 --- a/e2e/ruby/rails/demo/app/controllers/errors_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/errors_controller.rb @@ -2,8 +2,18 @@ class ErrorsController < ApplicationController def create - 1 / 0 - rescue StandardError => e - Highlight.exception(e, { foo: 'bar' }) + LaunchDarklyObservability.in_span('error-handling-example', attributes: { 'foo' => 'bar' }) do |span| + begin + 1 / 0 + rescue StandardError => e + # Record the exception using the convenience method + LaunchDarklyObservability.record_exception(e) + + # Also log it + Rails.logger.error "Exception occurred: #{e.message}" + end + end + + head :no_content end end diff --git a/e2e/ruby/rails/demo/app/controllers/flags_controller.rb b/e2e/ruby/rails/demo/app/controllers/flags_controller.rb index 124b03902..35ae92e48 100644 --- a/e2e/ruby/rails/demo/app/controllers/flags_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/flags_controller.rb @@ -9,13 +9,13 @@ def index context = build_context # Get all flags from LaunchDarkly - state = $ld_client.all_flags_state(context, details_only_for_tracked_flags: false) + state = Rails.configuration.ld_client.all_flags_state(context, details_only_for_tracked_flags: false) @all_flags_valid = state.valid? # Build evaluations from all available flags @evaluations = {} state.values_map.each_key do |flag_key| - @evaluations[flag_key] = $ld_client.variation_detail(flag_key, context, nil) + @evaluations[flag_key] = Rails.configuration.ld_client.variation_detail(flag_key, context, nil) end respond_to do |format| @@ -30,7 +30,7 @@ def show context = build_context flag_key = params[:id] - detail = $ld_client.variation_detail(flag_key, context, nil) + detail = Rails.configuration.ld_client.variation_detail(flag_key, context, nil) respond_to do |format| format.html { @flag_key = flag_key; @detail = detail } @@ -50,7 +50,7 @@ def evaluate **context_data.except(:key, :kind).to_h.symbolize_keys }) - detail = $ld_client.variation_detail(flag_key, context, params[:default]) + detail = Rails.configuration.ld_client.variation_detail(flag_key, context, params[:default]) render json: format_detail(flag_key, detail) end @@ -66,7 +66,7 @@ def batch end results = flag_keys.each_with_object({}) do |key, hash| - hash[key] = $ld_client.variation(key, context, nil) + hash[key] = Rails.configuration.ld_client.variation(key, context, nil) end render json: { evaluations: results, context_key: context.key } @@ -76,7 +76,7 @@ def batch # Get all flag states (demonstrates all_flags_state method) def all_flags context = build_context - state = $ld_client.all_flags_state(context) + state = Rails.configuration.ld_client.all_flags_state(context) render json: { valid: state.valid?, diff --git a/e2e/ruby/rails/demo/app/controllers/logs_controller.rb b/e2e/ruby/rails/demo/app/controllers/logs_controller.rb index d01db52ee..285b77315 100644 --- a/e2e/ruby/rails/demo/app/controllers/logs_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/logs_controller.rb @@ -2,10 +2,13 @@ class LogsController < ApplicationController def create - Highlight.log('info', 'hello, world!', { foo: 'bar' }) + # Rails logger is automatically instrumented by OpenTelemetry + Rails.logger.info "hello, world! foo=bar" + head :no_content end def create_with_hash Rails.logger.info(test: 'ing', foo: 'bar') + head :no_content end end diff --git a/e2e/ruby/rails/demo/app/controllers/pages_controller.rb b/e2e/ruby/rails/demo/app/controllers/pages_controller.rb index 6c5aa12e5..08dd28c4c 100644 --- a/e2e/ruby/rails/demo/app/controllers/pages_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/pages_controller.rb @@ -10,7 +10,7 @@ def home }) # Get all available flags (automatically traced by the observability plugin) - state = $ld_client.all_flags_state(@context) + state = Rails.configuration.ld_client.all_flags_state(@context) @flags_valid = state.valid? @flag_count = state.values_map.size diff --git a/e2e/ruby/rails/demo/app/controllers/traces_controller.rb b/e2e/ruby/rails/demo/app/controllers/traces_controller.rb index 8996a713b..622305aa6 100644 --- a/e2e/ruby/rails/demo/app/controllers/traces_controller.rb +++ b/e2e/ruby/rails/demo/app/controllers/traces_controller.rb @@ -2,21 +2,28 @@ class TracesController < ApplicationController def create - Highlight.start_span('example-trace-outer') do + LaunchDarklyObservability.in_span('example-trace-outer') do |outer_span| sleep(0.1) trace = Trace.new(name: 'trace', kind: 'internal') - Highlight.start_span('example-trace-inner') do + + LaunchDarklyObservability.in_span('example-trace-inner', attributes: { 'trace.operation' => 'save' }) do |inner_span| sleep(0.2) - trace.save! end + outer_span.set_attribute('trace.operation', 'update') trace.update!(name: 'trace-updated') end + + head :no_content end def custom_project_id - Highlight.start_span('example-trace-2', { Highlight::H::HIGHLIGHT_PROJECT_ATTRIBUTE => '56gl9g91' }) + LaunchDarklyObservability.in_span('example-trace-2', attributes: { 'launchdarkly.project_id' => '56gl9g91' }) do |span| + sleep(0.1) + end + + head :no_content end end diff --git a/e2e/ruby/rails/demo/app/views/flags/show.html.erb b/e2e/ruby/rails/demo/app/views/flags/show.html.erb new file mode 100644 index 000000000..b3fa7a4ac --- /dev/null +++ b/e2e/ruby/rails/demo/app/views/flags/show.html.erb @@ -0,0 +1,36 @@ +
+
+

Flag Evaluation

+ +
+
+

Flag Key

+ <%= @flag_key %> +
+ +
+

Value

+
+ <%= @detail.value.inspect %> +
+
+ +
+

Variation Index

+

<%= @detail.variation_index || 'N/A' %>

+
+ + <% if @detail.reason %> +
+

Reason

+
<%= JSON.pretty_generate(@detail.reason.to_json) %>
+
+ <% end %> +
+ +
+ <%= link_to "← Back to All Flags", flags_path, class: "bg-gray-500 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded" %> + <%= link_to "View JSON", flag_path(@flag_key, format: :json), class: "bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded", target: "_blank" %> +
+
+
diff --git a/e2e/ruby/rails/demo/app/views/layouts/application.html.erb b/e2e/ruby/rails/demo/app/views/layouts/application.html.erb index 44bac9e62..9e3f8879f 100644 --- a/e2e/ruby/rails/demo/app/views/layouts/application.html.erb +++ b/e2e/ruby/rails/demo/app/views/layouts/application.html.erb @@ -1,28 +1,11 @@ - Highlight Ruby Demo + LaunchDarkly Observability Ruby Demo <%= csrf_meta_tags %> <%= csp_meta_tag %> - <%= highlight_traceparent_meta %> - - <%# use the localhost endpoint for testing against firstload dev %> - <%# %> - - + <%= launchdarkly_traceparent_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb index 9df2c81d8..911534c22 100644 --- a/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb +++ b/e2e/ruby/rails/demo/config/initializers/launchdarkly.rb @@ -1,28 +1,20 @@ -# frozen_string_literal: true - require 'launchdarkly-server-sdk' require 'launchdarkly_observability' -# Create observability plugin (SDK key and environment automatically inferred) observability_plugin = LaunchDarklyObservability::Plugin.new( otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', LaunchDarklyObservability::DEFAULT_ENDPOINT), service_name: 'launchdarkly-ruby-demo-backend', service_version: '1.0.0' ) -# Initialize LaunchDarkly client with real SDK key sdk_key = ENV.fetch('LAUNCHDARKLY_SDK_KEY') do Rails.logger.warn '[LaunchDarkly] LAUNCHDARKLY_SDK_KEY not set, client will not connect' nil end -config = LaunchDarkly::Config.new( - plugins: [observability_plugin] -) +config = LaunchDarkly::Config.new(plugins: [observability_plugin]) -$ld_client = LaunchDarkly::LDClient.new(sdk_key, config) +Rails.configuration.ld_client = LaunchDarkly::LDClient.new(sdk_key, config) # Ensure clean shutdown on application exit -at_exit { $ld_client.close } - -Rails.logger.info '[LaunchDarkly] Client initialized with observability plugin' +at_exit { Rails.configuration.ld_client.close } diff --git a/e2e/ruby/rails/demo/config/puma.rb b/e2e/ruby/rails/demo/config/puma.rb index 91511f057..ffa06610f 100644 --- a/e2e/ruby/rails/demo/config/puma.rb +++ b/e2e/ruby/rails/demo/config/puma.rb @@ -41,5 +41,11 @@ # # preload_app! +# Reinitialize LaunchDarkly client after forking workers +# This is required when using workers (clustered mode) +# on_worker_boot do +# Rails.configuration.ld_client.postfork +# end + # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart diff --git a/e2e/ruby/sinatra/ruby2-logging/.ruby-version b/e2e/ruby/sinatra/ruby2-logging/.ruby-version index 6a81b4c83..a0891f563 100644 --- a/e2e/ruby/sinatra/ruby2-logging/.ruby-version +++ b/e2e/ruby/sinatra/ruby2-logging/.ruby-version @@ -1 +1 @@ -2.7.8 +3.3.4 diff --git a/e2e/ruby/sinatra/ruby2-logging/.tool-versions b/e2e/ruby/sinatra/ruby2-logging/.tool-versions index 59511e1d2..05668b726 100644 --- a/e2e/ruby/sinatra/ruby2-logging/.tool-versions +++ b/e2e/ruby/sinatra/ruby2-logging/.tool-versions @@ -1 +1 @@ -ruby 2.7.8 +ruby 3.3.4 diff --git a/e2e/ruby/sinatra/ruby2-logging/Gemfile b/e2e/ruby/sinatra/ruby2-logging/Gemfile index b2822a030..6c93d8139 100644 --- a/e2e/ruby/sinatra/ruby2-logging/Gemfile +++ b/e2e/ruby/sinatra/ruby2-logging/Gemfile @@ -1,10 +1,11 @@ source 'https://rubygems.org' -ruby '~> 2.7.0' +ruby '~> 3.3.4' gem 'sinatra' gem 'activesupport', '~> 6.1.0' # For Rails.logger compatibility -gem 'highlight_io', '0.2.2' +gem 'launchdarkly-server-sdk', '~> 8.0' +gem 'launchdarkly-observability', path: '../../../../sdk/@launchdarkly/observability-ruby' gem "rackup", "~> 2.2" gem "puma", "~> 6.6" diff --git a/e2e/ruby/sinatra/ruby2-logging/Gemfile.lock b/e2e/ruby/sinatra/ruby2-logging/Gemfile.lock index ac395b236..0fc88fea8 100644 --- a/e2e/ruby/sinatra/ruby2-logging/Gemfile.lock +++ b/e2e/ruby/sinatra/ruby2-logging/Gemfile.lock @@ -1,3 +1,13 @@ +PATH + remote: ../../../../sdk/@launchdarkly/observability-ruby + specs: + launchdarkly-observability (0.1.0) + launchdarkly-server-sdk (>= 8.0) + opentelemetry-exporter-otlp (~> 0.28) + opentelemetry-instrumentation-all (~> 0.62) + opentelemetry-sdk (~> 1.4) + opentelemetry-semantic_conventions (~> 1.10) + GEM remote: https://rubygems.org/ specs: @@ -7,212 +17,230 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.0.1) concurrent-ruby (1.3.5) - google-protobuf (3.25.6) - googleapis-common-protos-types (1.18.0) - google-protobuf (>= 3.18, < 5.a) - grpc (1.65.2) - google-protobuf (>= 3.25, < 5.0) - googleapis-common-protos-types (~> 1.0) - highlight_io (0.2.2) - grpc (~> 1.52) - opentelemetry-exporter-otlp (~> 0.24.0) - opentelemetry-instrumentation-all (~> 0.32.0) - opentelemetry-sdk (~> 1.2) - opentelemetry-semantic_conventions (~> 1.8.0) + domain_name (0.6.20240107) + ffi (1.17.3) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake + google-protobuf (4.33.4) + bigdecimal + rake (>= 13) + googleapis-common-protos-types (1.22.0) + google-protobuf (~> 4.26) + http (5.3.1) + addressable (~> 2.8) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.1.0) + domain_name (~> 0.5) + http-form_data (2.3.0) i18n (1.14.7) concurrent-ruby (~> 1.0) + json (2.18.1) + launchdarkly-server-sdk (8.12.0) + benchmark (~> 0.1, >= 0.1.1) + concurrent-ruby (~> 1.1) + http (>= 4.4.0, < 6.0.0) + json (~> 2.3) + ld-eventsource (= 2.5.0) + observer (~> 0.1.2) + openssl (>= 3.1.2, < 5.0) + semantic (~> 1.6) + zlib (~> 3.1) + ld-eventsource (2.5.0) + concurrent-ruby (~> 1.0) + http (>= 4.4.1, < 6.0.0) + llhttp-ffi (0.5.1) + ffi-compiler (~> 1.0) + rake (~> 13.0) logger (1.6.6) minitest (5.25.5) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) nio4r (2.7.4) - opentelemetry-api (1.1.0) - opentelemetry-common (0.19.7) + observer (0.1.2) + openssl (4.0.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.24.2) - google-protobuf (~> 3.19) + opentelemetry-exporter-otlp (0.31.1) + google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.19.6) - opentelemetry-sdk (~> 1.2) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.10) opentelemetry-semantic_conventions - opentelemetry-instrumentation-action_pack (0.5.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.4.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-active_support (~> 0.1) - opentelemetry-instrumentation-base (~> 0.20) - opentelemetry-instrumentation-active_job (0.4.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-active_model_serializers (0.19.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-active_record (0.5.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - ruby2_keywords - opentelemetry-instrumentation-active_support (0.3.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-all (0.32.0) - opentelemetry-instrumentation-active_model_serializers (~> 0.19.0) - opentelemetry-instrumentation-aws_sdk (~> 0.3.0) - opentelemetry-instrumentation-bunny (~> 0.19.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.20.0) - opentelemetry-instrumentation-dalli (~> 0.22.0) - opentelemetry-instrumentation-delayed_job (~> 0.19.0) - opentelemetry-instrumentation-ethon (~> 0.20.0) - opentelemetry-instrumentation-excon (~> 0.20.0) - opentelemetry-instrumentation-faraday (~> 0.22.0) - opentelemetry-instrumentation-graphql (~> 0.23.0) - opentelemetry-instrumentation-http (~> 0.21.0) - opentelemetry-instrumentation-http_client (~> 0.21.0) - opentelemetry-instrumentation-koala (~> 0.19.0) - opentelemetry-instrumentation-lmdb (~> 0.21.0) - opentelemetry-instrumentation-mongo (~> 0.21.0) - opentelemetry-instrumentation-mysql2 (~> 0.22.0) - opentelemetry-instrumentation-net_http (~> 0.21.0) - opentelemetry-instrumentation-pg (~> 0.23.0) - opentelemetry-instrumentation-que (~> 0.5.0) - opentelemetry-instrumentation-racecar (~> 0.1.0) - opentelemetry-instrumentation-rack (~> 0.22.0) - opentelemetry-instrumentation-rails (~> 0.25.0) - opentelemetry-instrumentation-rake (~> 0.1.0) - opentelemetry-instrumentation-rdkafka (~> 0.2.0) - opentelemetry-instrumentation-redis (~> 0.24.0) - opentelemetry-instrumentation-resque (~> 0.3.0) - opentelemetry-instrumentation-restclient (~> 0.21.0) - opentelemetry-instrumentation-ruby_kafka (~> 0.19.0) - opentelemetry-instrumentation-sidekiq (~> 0.22.0) - opentelemetry-instrumentation-sinatra (~> 0.21.0) - opentelemetry-instrumentation-trilogy (~> 0.52.0) - opentelemetry-instrumentation-aws_sdk (0.3.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-base (0.21.1) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-mysql (0.4.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) + opentelemetry-helpers-sql (0.3.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.2) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-active_record (0.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-all (0.90.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-anthropic (~> 0.3.0) + opentelemetry-instrumentation-aws_lambda (~> 0.6.0) + opentelemetry-instrumentation-aws_sdk (~> 0.11.0) + opentelemetry-instrumentation-bunny (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-dalli (~> 0.29.0) + opentelemetry-instrumentation-delayed_job (~> 0.25.1) + opentelemetry-instrumentation-ethon (~> 0.27.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-grape (~> 0.5.0) + opentelemetry-instrumentation-graphql (~> 0.31.1) + opentelemetry-instrumentation-grpc (~> 0.4.1) + opentelemetry-instrumentation-gruf (~> 0.5.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-httpx (~> 0.6.0) + opentelemetry-instrumentation-koala (~> 0.23.0) + opentelemetry-instrumentation-lmdb (~> 0.25.0) + opentelemetry-instrumentation-mongo (~> 0.25.0) + opentelemetry-instrumentation-mysql2 (~> 0.33.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) + opentelemetry-instrumentation-que (~> 0.12.0) + opentelemetry-instrumentation-racecar (~> 0.6.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.1) + opentelemetry-instrumentation-rake (~> 0.5.0) + opentelemetry-instrumentation-rdkafka (~> 0.9.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-resque (~> 0.8.0) + opentelemetry-instrumentation-restclient (~> 0.26.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.24.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.1) + opentelemetry-instrumentation-sinatra (~> 0.28.0) + opentelemetry-instrumentation-trilogy (~> 0.66.0) + opentelemetry-instrumentation-anthropic (0.3.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_lambda (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_sdk (0.11.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-bunny (0.19.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-concurrent_ruby (0.20.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-dalli (0.22.2) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-delayed_job (0.19.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-ethon (0.20.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-excon (0.20.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-faraday (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-graphql (0.23.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-http (0.21.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-http_client (0.21.0) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-koala (0.19.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-lmdb (0.21.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-mongo (0.21.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-mysql2 (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-net_http (0.21.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-pg (0.23.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-que (0.5.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-racecar (0.1.2) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rack (0.22.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rails (0.25.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_pack (~> 0.5.0) - opentelemetry-instrumentation-action_view (~> 0.4.0) - opentelemetry-instrumentation-active_job (~> 0.4.0) - opentelemetry-instrumentation-active_record (~> 0.5.0) - opentelemetry-instrumentation-active_support (~> 0.3.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rake (0.1.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rdkafka (0.2.3) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-redis (0.24.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-resque (0.3.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-restclient (0.21.0) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-ruby_kafka (0.19.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-sidekiq (0.22.1) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-sinatra (0.21.5) - opentelemetry-api (~> 1.0) - opentelemetry-common (~> 0.19.3) - opentelemetry-instrumentation-base (~> 0.21.0) - opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-trilogy (0.52.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.21.0) + opentelemetry-instrumentation-bunny (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-concurrent_ruby (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-dalli (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-delayed_job (0.25.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ethon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grape (0.5.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-graphql (0.31.2) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grpc (0.4.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-gruf (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-httpx (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-koala (0.23.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-lmdb (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mongo (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mysql2 (0.33.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.35.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-que (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-racecar (0.6.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-rake (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rdkafka (0.9.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-resque (0.8.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-restclient (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ruby_kafka (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sinatra (0.28.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-trilogy (0.66.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) opentelemetry-semantic_conventions (>= 1.8.0) - opentelemetry-registry (0.2.0) + opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.2.1) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.19.3) + opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.8.0) + opentelemetry-semantic_conventions (1.36.0) opentelemetry-api (~> 1.0) + public_suffix (7.0.2) puma (6.6.0) nio4r (~> 2.0) rack (3.1.12) @@ -225,7 +253,9 @@ GEM rack (>= 3.0.0) rackup (2.2.1) rack (>= 3) + rake (13.3.1) ruby2_keywords (0.0.5) + semantic (1.6.1) sinatra (4.1.1) logger (>= 1.6.0) mustermann (~> 3.0) @@ -237,19 +267,21 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) zeitwerk (2.6.18) + zlib (3.2.2) PLATFORMS ruby DEPENDENCIES activesupport (~> 6.1.0) - highlight_io (= 0.2.2) + launchdarkly-observability! + launchdarkly-server-sdk (~> 8.0) puma (~> 6.6) rackup (~> 2.2) sinatra RUBY VERSION - ruby 2.7.8p225 + ruby 3.3.4p94 BUNDLED WITH 2.1.4 diff --git a/e2e/ruby/sinatra/ruby2-logging/app.rb b/e2e/ruby/sinatra/ruby2-logging/app.rb index c5bf2d59e..c9abb7bfa 100644 --- a/e2e/ruby/sinatra/ruby2-logging/app.rb +++ b/e2e/ruby/sinatra/ruby2-logging/app.rb @@ -1,19 +1,28 @@ require 'sinatra' require 'active_support' -require 'highlight' +require 'launchdarkly-server-sdk' +require 'launchdarkly_observability' require 'logger' -# Initialize Highlight - modified for v0.2.0 -Highlight::H.new('1jdkoe52', environment: 'development', otlp_endpoint: 'http://localhost:4318') do |c| - c.service_name = 'highlight-ruby-v2' - c.service_version = '1.0.0' -end +# Initialize LaunchDarkly Observability +observability_plugin = LaunchDarklyObservability::Plugin.new( + otlp_endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'), + service_name: 'launchdarkly-sinatra-demo', + service_version: '1.0.0' +) + +# Initialize LaunchDarkly client +sdk_key = ENV.fetch('LAUNCHDARKLY_SDK_KEY', 'sdk-test-key') +config = LaunchDarkly::Config.new(plugins: [observability_plugin]) +$ld_client = LaunchDarkly::LDClient.new(sdk_key, config) + +at_exit { $ld_client.close } # Set up Rails-like logger module Rails class << self def logger - @logger ||= Highlight::Logger.new($stdout) + @logger ||= Logger.new($stdout) end end end @@ -30,26 +39,38 @@ def initialize(name, data) # Routes get '/' do - event = TestEvent.new( - :supporter_notified_of_shifts_change, - { - supporter_id: 6875, - event_id: 34, - edited_shifts_ids: [200, 203] - } - ) - - # Log using Rails.logger style - Rails.logger.info(published_event: event.name, **event.data) - - # Basic log - Rails.logger.info("Test log...") - - 'Event logged! Check your logs.' + LaunchDarklyObservability.in_span('log-event-example') do |span| + event = TestEvent.new( + :supporter_notified_of_shifts_change, + { + supporter_id: 6875, + event_id: 34, + edited_shifts_ids: [200, 203] + } + ) + + span.set_attribute('event.name', event.name.to_s) + span.set_attribute('event.supporter_id', event.data[:supporter_id]) + + # Log using Rails.logger style + Rails.logger.info(published_event: event.name, **event.data) + + # Basic log + Rails.logger.info("Test log...") + + 'Event logged! Check your logs.' + end end # Error route to test error logging get '/error' do - Rails.logger.error("Test error logging") - raise "Test error" + LaunchDarklyObservability.in_span('error-example') do |span| + begin + Rails.logger.error("Test error logging") + raise "Test error" + rescue StandardError => e + LaunchDarklyObservability.record_exception(e) + raise + end + end end diff --git a/sdk/@launchdarkly/observability-ruby/.ruby-version b/sdk/@launchdarkly/observability-ruby/.ruby-version new file mode 100644 index 000000000..a0891f563 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/.ruby-version @@ -0,0 +1 @@ +3.3.4 diff --git a/sdk/@launchdarkly/observability-ruby/MANUAL_INSTRUMENTATION.md b/sdk/@launchdarkly/observability-ruby/MANUAL_INSTRUMENTATION.md new file mode 100644 index 000000000..82aaafb8b --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/MANUAL_INSTRUMENTATION.md @@ -0,0 +1,259 @@ +# Manual Instrumentation Guide + +The LaunchDarkly Observability plugin provides convenient methods for creating custom spans, matching the OpenTelemetry Ruby SDK naming conventions. + +## Quick Start + +```ruby +require 'launchdarkly_observability' + +# Create a custom span (same API as OpenTelemetry's tracer.in_span) +LaunchDarklyObservability.in_span('my-operation') do |span| + span.set_attribute('custom.key', 'value') + # Your code here +end +``` + +## Creating Custom Spans + +### Basic Span + +```ruby +LaunchDarklyObservability.in_span('database-query') do |span| + result = execute_query + span.set_attribute('rows.returned', result.count) +end +``` + +### Span with Initial Attributes + +```ruby +LaunchDarklyObservability.in_span('api-call', attributes: { + 'api.endpoint' => '/users', + 'api.method' => 'GET' +}) do |span| + response = make_api_call + span.set_attribute('api.status', response.code) +end +``` + +### Nested Spans + +```ruby +LaunchDarklyObservability.in_span('process-order') do |outer_span| + outer_span.set_attribute('order.id', order_id) + + # Nested span for payment + LaunchDarklyObservability.in_span('validate-payment') do |payment_span| + validate_payment(order) + payment_span.set_attribute('payment.method', 'credit_card') + end + + # Nested span for inventory + LaunchDarklyObservability.in_span('update-inventory') do |inventory_span| + update_inventory(order) + end + + outer_span.set_attribute('order.status', 'completed') +end +``` + +## Recording Exceptions + +```ruby +LaunchDarklyObservability.in_span('risky-operation') do |span| + begin + perform_operation + rescue StandardError => e + # Record exception with additional context + LaunchDarklyObservability.record_exception(e, attributes: { + 'retry_count' => 3, + 'operation_id' => operation_id + }) + raise # Re-raise the exception + end +end +``` + +## Getting the Current Trace ID + +Useful for correlating logs with traces: + +```ruby +LaunchDarklyObservability.in_span('process-request') do |span| + trace_id = LaunchDarklyObservability.current_trace_id + + logger.info "Processing request with trace: #{trace_id}" + process_request +end +``` + +## Rails Controller Helpers + +When using Rails, you also have access to controller-specific helpers: + +```ruby +class OrdersController < ApplicationController + def create + # Get current trace ID + trace_id = launchdarkly_trace_id + Rails.logger.info "Creating order: #{trace_id}" + + # Create custom span (Rails helper) + with_launchdarkly_span('process-payment', attributes: { 'amount' => params[:amount] }) do |span| + process_payment + span.set_attribute('payment.status', 'success') + end + + # Record exception (Rails helper) + begin + finalize_order + rescue => e + record_launchdarkly_exception(e, attributes: { 'order_id' => @order.id }) + raise + end + end +end +``` + +## Non-Rails Applications + +The module-level methods work in any Ruby application: + +```ruby +# Sinatra example +require 'sinatra' +require 'launchdarkly_observability' + +get '/users/:id' do + LaunchDarklyObservability.in_span('fetch-user', attributes: { 'user.id' => params[:id] }) do |span| + user = User.find(params[:id]) + span.set_attribute('user.name', user.name) + user.to_json + end +end + +# Plain Ruby script +LaunchDarklyObservability.in_span('data-processing') do |span| + files = Dir.glob('data/*.csv') + span.set_attribute('files.count', files.length) + + files.each do |file| + LaunchDarklyObservability.in_span('process-file', attributes: { 'file.name' => file }) do |file_span| + process_csv(file) + end + end +end +``` + +## Comparison: Plugin API vs Raw OpenTelemetry + +### Using the Plugin API (Recommended) + +The plugin API matches OpenTelemetry's naming but eliminates boilerplate: + +```ruby +# Same method name as OpenTelemetry, but no need to get a tracer +LaunchDarklyObservability.in_span('operation', attributes: { 'key' => 'value' }) do |span| + # Your code +end + +# Convenience methods for common operations +LaunchDarklyObservability.record_exception(error) +LaunchDarklyObservability.current_trace_id +``` + +### Using Raw OpenTelemetry API + +```ruby +# Need to get a tracer first +tracer = OpenTelemetry.tracer_provider.tracer('my-component', '1.0.0') + +tracer.in_span('operation', attributes: { 'key' => 'value' }) do |span| + # Your code +end + +# More verbose exception recording +span = OpenTelemetry::Trace.current_span +span.record_exception(error) +span.status = OpenTelemetry::Trace::Status.error(error.message) + +# More verbose trace ID retrieval +span = OpenTelemetry::Trace.current_span +span.context.hex_trace_id if span&.context&.valid? +``` + +The plugin API provides the same familiar `in_span` method name while eliminating boilerplate. + +## Best Practices + +1. **Use descriptive span names**: Use kebab-case names that describe the operation + ```ruby + LaunchDarklyObservability.in_span('validate-payment') # Good + LaunchDarklyObservability.in_span('do_stuff') # Bad + ``` + +2. **Add meaningful attributes**: Include relevant context as span attributes + ```ruby + LaunchDarklyObservability.in_span('database-query', attributes: { + 'db.table' => 'users', + 'db.operation' => 'select', + 'db.rows_returned' => results.count + }) + ``` + +3. **Always re-raise exceptions**: After recording an exception, re-raise it unless you're handling it + ```ruby + rescue => e + LaunchDarklyObservability.record_exception(e) + raise # Important! + end + ``` + +4. **Keep spans focused**: Create separate spans for distinct operations rather than one large span + ```ruby + # Good - separate spans + LaunchDarklyObservability.in_span('fetch-data') { fetch } + LaunchDarklyObservability.in_span('process-data') { process } + + # Bad - one large span + LaunchDarklyObservability.in_span('fetch-and-process') do + fetch + process + end + ``` + +5. **Include trace IDs in logs**: Use `current_trace_id` for log correlation + ```ruby + trace_id = LaunchDarklyObservability.current_trace_id + Rails.logger.info "Starting processing [trace: #{trace_id}]" + ``` + +## API Reference + +### `LaunchDarklyObservability.in_span(name, attributes: {})` + +Creates a new span and executes the given block within its context. Matches the OpenTelemetry `tracer.in_span` API. + +**Parameters:** +- `name` (String): The name of the span +- `attributes` (Hash): Optional initial attributes for the span + +**Yields:** +- `span` (OpenTelemetry::Trace::Span): The created span object + +**Returns:** The result of the block + +### `LaunchDarklyObservability.record_exception(exception, attributes: {})` + +Records an exception in the current span and sets the span status to error. + +**Parameters:** +- `exception` (Exception): The exception to record +- `attributes` (Hash): Optional additional attributes + +### `LaunchDarklyObservability.current_trace_id` + +Returns the current trace ID in hexadecimal format. + +**Returns:** String (32 hex characters) or nil if no active span diff --git a/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md index 74db23ee7..249c4c3d9 100644 --- a/sdk/@launchdarkly/observability-ruby/README.md +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -119,23 +119,23 @@ end ```ruby LaunchDarklyObservability::Plugin.new( # All parameters are optional - SDK key and environment are automatically inferred - + # Optional: Custom OTLP endpoint (default: LaunchDarkly's endpoint) otlp_endpoint: 'https://otel.observability.app.launchdarkly.com:4318', - + # Optional: Environment override (default: inferred from SDK key) # Only specify for advanced scenarios like deployment-specific suffixes environment: 'production-canary', - + # Optional: Service identification service_name: 'my-service', service_version: '1.0.0', - + # Optional: Enable/disable signal types enable_traces: true, # default: true enable_logs: true, # default: true enable_metrics: true, # default: true - + # Optional: Custom instrumentation configuration instrumentations: { 'OpenTelemetry::Instrumentation::Rails' => { enable_recognize_route: true }, @@ -204,14 +204,14 @@ class MyController < ApplicationController # Get current trace ID for logging trace_id = launchdarkly_trace_id Rails.logger.info "Processing request with trace: #{trace_id}" - + # Create custom spans with_launchdarkly_span('custom-operation', attributes: { 'custom.key' => 'value' }) do |span| # Your code here span.set_attribute('result', 'success') end end - + def create # Record exceptions begin @@ -255,13 +255,13 @@ LaunchDarklyObservability::Plugin.new( instrumentations: { # Disable specific instrumentations 'OpenTelemetry::Instrumentation::Redis' => { enabled: false }, - + # Configure instrumentations 'OpenTelemetry::Instrumentation::ActiveRecord' => { db_statement: :obfuscate, # Mask sensitive data obfuscation_limit: 2000 }, - + # Skip certain endpoints 'OpenTelemetry::Instrumentation::Rack' => { untraced_endpoints: ['/health', '/metrics'] @@ -274,17 +274,84 @@ LaunchDarklyObservability::Plugin.new( ### Creating Custom Spans +The LaunchDarkly Observability plugin provides a clean API matching OpenTelemetry conventions for creating custom spans: + ```ruby -tracer = OpenTelemetry.tracer_provider.tracer('my-component') +# Simple span creation +LaunchDarklyObservability.in_span('database-query') do |span| + span.set_attribute('db.table', 'users') + span.set_attribute('db.operation', 'select') -tracer.in_span('custom-operation') do |span| - span.set_attribute('custom.attribute', 'value') - # Your code here - - if error_occurred - span.record_exception(error) - span.status = OpenTelemetry::Trace::Status.error('Operation failed') + results = execute_query +end + +# With initial attributes +LaunchDarklyObservability.in_span('api-call', attributes: { 'api.endpoint' => '/users' }) do |span| + response = make_api_call + span.set_attribute('api.status', response.code) +end + +# Nested spans +LaunchDarklyObservability.in_span('process-order') do |outer_span| + outer_span.set_attribute('order.id', order_id) + + LaunchDarklyObservability.in_span('validate-payment') do |inner_span| + validate_payment(order) + end + + LaunchDarklyObservability.in_span('update-inventory') do |inner_span| + update_inventory(order) + end +end +``` + +### Recording Exceptions + +```ruby +begin + risky_operation +rescue StandardError => e + # Record the exception in the current span + LaunchDarklyObservability.record_exception(e, attributes: { 'retry_count' => 3 }) + raise +end +``` + +### Getting Current Trace ID + +```ruby +# Get the current trace ID for logging or debugging +trace_id = LaunchDarklyObservability.current_trace_id +logger.info "Processing request: #{trace_id}" +``` + +### Rails Controller Helpers + +In Rails controllers, you can also use these helper methods: + +```ruby +class MyController < ApplicationController + def index + # Get current trace ID + trace_id = launchdarkly_trace_id + Rails.logger.info "Processing request with trace: #{trace_id}" + + # Create custom spans (Rails helper - equivalent to LaunchDarklyObservability.in_span) + with_launchdarkly_span('custom-operation', attributes: { 'custom.key' => 'value' }) do |span| + # Your code here + span.set_attribute('result', 'success') + end + end + + def create + # Record exceptions (Rails helper - equivalent to LaunchDarklyObservability.record_exception) + begin + process_something + rescue => e + record_launchdarkly_exception(e) + raise + end end end ``` @@ -296,6 +363,25 @@ end Rails.logger.info "Processing flag evaluation" # Includes trace_id, span_id ``` +### Using Raw OpenTelemetry API + +If you need more control, you can still use the OpenTelemetry API directly: + +```ruby +tracer = OpenTelemetry.tracer_provider.tracer('my-component') + +tracer.in_span('custom-operation') do |span| + span.set_attribute('custom.attribute', 'value') + + # Your code here + + if error_occurred + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error('Operation failed') + end +end +``` + ## Troubleshooting ### Spans Not Appearing @@ -349,11 +435,11 @@ class ActiveSupport::TestCase ) end end - + teardown do @exporter.reset end - + def finished_spans @exporter.finished_spans end @@ -371,6 +457,28 @@ LaunchDarklyObservability.init # Check if initialized LaunchDarklyObservability.initialized? # => true +# Create a custom span for manual instrumentation (matches OpenTelemetry API) +LaunchDarklyObservability.in_span('operation-name') do |span| + span.set_attribute('key', 'value') + # your code +end + +# Create a span with initial attributes +LaunchDarklyObservability.in_span('operation-name', attributes: { 'key' => 'value' }) do |span| + # your code +end + +# Record an exception in the current span +begin + risky_operation +rescue => e + LaunchDarklyObservability.record_exception(e, attributes: { 'context' => 'value' }) + raise +end + +# Get the current trace ID +trace_id = LaunchDarklyObservability.current_trace_id # => "abc123..." + # Flush pending telemetry LaunchDarklyObservability.flush diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb index 5c5b0e5c7..8e3a5dbab 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb @@ -56,6 +56,74 @@ def initialized? !@instance.nil? end + # Create a custom span for manual instrumentation + # + # This method matches the OpenTelemetry API naming convention for consistency. + # + # @param name [String] The span name + # @param attributes [Hash] Optional span attributes + # @yield [span] Block to execute within the span context + # @return The result of the block + # + # @example Create a custom span + # LaunchDarklyObservability.in_span('database-query') do |span| + # span.set_attribute('db.table', 'users') + # perform_query + # end + def in_span(name, attributes: {}) + unless defined?(OpenTelemetry) && OpenTelemetry.tracer_provider + return yield if block_given? + return + end + + tracer = OpenTelemetry.tracer_provider.tracer( + 'launchdarkly-observability', + LaunchDarklyObservability::VERSION + ) + + tracer.in_span(name, attributes: attributes) do |span| + yield(span) if block_given? + end + end + + # Record an exception in the current span + # + # @param exception [Exception] The exception to record + # @param attributes [Hash] Additional attributes + # + # @example Record an exception + # begin + # risky_operation + # rescue => e + # LaunchDarklyObservability.record_exception(e, foo: 'bar') + # raise + # end + def record_exception(exception, attributes: {}) + return unless defined?(OpenTelemetry) + + span = OpenTelemetry::Trace.current_span + return unless span + + span.record_exception(exception, attributes: attributes) + span.status = OpenTelemetry::Trace::Status.error(exception.message) + end + + # Get the current trace ID + # + # @return [String, nil] The current trace ID in hex format + # + # @example Get trace ID for logging + # trace_id = LaunchDarklyObservability.current_trace_id + # logger.info "Processing request: #{trace_id}" + def current_trace_id + return nil unless defined?(OpenTelemetry) + + span = OpenTelemetry::Trace.current_span + return nil unless span&.context&.valid? + + span.context.hex_trace_id + end + # Flush all pending telemetry data def flush @instance&.flush diff --git a/sdk/@launchdarkly/observability-ruby/test/module_methods_test.rb b/sdk/@launchdarkly/observability-ruby/test/module_methods_test.rb new file mode 100644 index 000000000..705285153 --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/module_methods_test.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Tests for LaunchDarklyObservability module-level convenience methods +class ModuleMethodsTest < Minitest::Test + def setup + @exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + @span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + + OpenTelemetry::SDK.configure do |c| + c.add_span_processor(@span_processor) + end + end + + def teardown + @exporter.reset + end + + def test_in_span_creates_span + result = LaunchDarklyObservability.in_span('test-operation') do |span| + span.set_attribute('test.key', 'test.value') + 'operation result' + end + + assert_equal 'operation result', result + + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + assert_equal 'test-operation', span.name + assert_equal 'test.value', span.attributes['test.key'] + end + + def test_in_span_with_initial_attributes + LaunchDarklyObservability.in_span('test-operation', attributes: { 'initial.key' => 'initial.value' }) do |span| + span.set_attribute('added.key', 'added.value') + end + + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + assert_equal 'initial.value', span.attributes['initial.key'] + assert_equal 'added.value', span.attributes['added.key'] + end + + def test_in_span_nested_spans + LaunchDarklyObservability.in_span('outer-span') do |outer| + outer.set_attribute('level', 'outer') + + LaunchDarklyObservability.in_span('inner-span') do |inner| + inner.set_attribute('level', 'inner') + end + end + + spans = @exporter.finished_spans + assert_equal 2, spans.length + + inner_span = spans[0] + outer_span = spans[1] + + assert_equal 'inner-span', inner_span.name + assert_equal 'inner', inner_span.attributes['level'] + + assert_equal 'outer-span', outer_span.name + assert_equal 'outer', outer_span.attributes['level'] + + # Inner span should be child of outer span + assert_equal outer_span.span_id, inner_span.parent_span_id + end + + def test_record_exception + error = StandardError.new('Test error') + + LaunchDarklyObservability.in_span('test-operation') do |span| + begin + raise error + rescue StandardError => e + LaunchDarklyObservability.record_exception(e, attributes: { 'error.context' => 'test' }) + end + end + + spans = @exporter.finished_spans + assert_equal 1, spans.length + + span = spans.first + events = span.events + assert_equal 1, events.length + + event = events.first + assert_equal 'exception', event.name + assert_equal 'StandardError', event.attributes['exception.type'] + assert_equal 'Test error', event.attributes['exception.message'] + assert event.attributes['exception.stacktrace'] + + # Check span status (OpenTelemetry::Trace::Status::ERROR = 2) + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code + assert_equal 'Test error', span.status.description + end + + def test_current_trace_id + trace_id = nil + + LaunchDarklyObservability.in_span('test-operation') do |span| + trace_id = LaunchDarklyObservability.current_trace_id + end + + refute_nil trace_id + assert_match(/^[0-9a-f]{32}$/, trace_id) + + spans = @exporter.finished_spans + assert_equal 1, spans.length + assert_equal trace_id, spans.first.hex_trace_id + end + + def test_current_trace_id_without_span + # When not in a span, should return nil + trace_id = LaunchDarklyObservability.current_trace_id + assert_nil trace_id + end + + def test_in_span_without_block + # Should not raise error if called without block + result = LaunchDarklyObservability.in_span('test-operation') + assert_nil result + end + + def test_record_exception_without_span + # Should not raise error if called outside a span + error = StandardError.new('Test error') + LaunchDarklyObservability.record_exception(error) + + # No spans should be created + spans = @exporter.finished_spans + assert_equal 0, spans.length + end +end From 141135bdad0b0ccdee7f95ea157c3ef743f50833 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 13:11:19 -0800 Subject: [PATCH 5/7] Update config examples --- .../app/controllers/application_controller.rb | 6 ++++++ .../app/controllers/health_controller.rb | 8 ++++---- .../config/initializers/launchdarkly.rb | 4 ++-- sdk/@launchdarkly/observability-ruby/README.md | 18 +++++++++++++----- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/e2e/ruby/rails/api-only/app/controllers/application_controller.rb b/e2e/ruby/rails/api-only/app/controllers/application_controller.rb index 257a571b6..d48fc2bc4 100644 --- a/e2e/ruby/rails/api-only/app/controllers/application_controller.rb +++ b/e2e/ruby/rails/api-only/app/controllers/application_controller.rb @@ -1,3 +1,9 @@ class ApplicationController < ActionController::API include ActionController::Cookies + + private + + def ld_client + Rails.configuration.ld_client + end end diff --git a/e2e/ruby/rails/api-only/app/controllers/health_controller.rb b/e2e/ruby/rails/api-only/app/controllers/health_controller.rb index 977df53e2..33a9a1723 100644 --- a/e2e/ruby/rails/api-only/app/controllers/health_controller.rb +++ b/e2e/ruby/rails/api-only/app/controllers/health_controller.rb @@ -1,7 +1,7 @@ class HealthController < ApplicationController def index context = LaunchDarkly::LDContext.create({ key: 'health-check', kind: 'service' }) - state = $ld_client.all_flags_state(context) + state = ld_client.all_flags_state(context) render(json: { status: 'ok', @@ -23,12 +23,12 @@ def flags user_key = params[:user_key] || 'anonymous' context = LaunchDarkly::LDContext.create({ key: user_key, kind: 'user' }) - state = $ld_client.all_flags_state(context) + state = ld_client.all_flags_state(context) # Get detailed evaluations for all flags evaluations = {} state.values_map.each_key do |flag_key| - detail = $ld_client.variation_detail(flag_key, context, nil) + detail = ld_client.variation_detail(flag_key, context, nil) evaluations[flag_key] = { value: detail.value, variation_index: detail.variation_index, @@ -51,7 +51,7 @@ def flag user_key = params[:user_key] || 'anonymous' context = LaunchDarkly::LDContext.create({ key: user_key, kind: 'user' }) - detail = $ld_client.variation_detail(flag_key, context, nil) + detail = ld_client.variation_detail(flag_key, context, nil) render(json: { flag_key: flag_key, diff --git a/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb index d59823f6f..bf764c5d6 100644 --- a/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb +++ b/e2e/ruby/rails/api-only/config/initializers/launchdarkly.rb @@ -20,8 +20,8 @@ plugins: [observability_plugin] ) -$ld_client = LaunchDarkly::LDClient.new(sdk_key, config) +Rails.configuration.ld_client = LaunchDarkly::LDClient.new(sdk_key, config) -at_exit { $ld_client.close } +at_exit { Rails.configuration.ld_client.close } Rails.logger.info '[LaunchDarkly] Client initialized with observability plugin' diff --git a/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md index 249c4c3d9..1a860e3ed 100644 --- a/sdk/@launchdarkly/observability-ruby/README.md +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -80,20 +80,28 @@ observability = LaunchDarklyObservability::Plugin.new( service_version: '1.0.0' ) -# Initialize LaunchDarkly client -$ld_client = LaunchDarkly::LDClient.new( +# Initialize LaunchDarkly client using Rails configuration +config = LaunchDarkly::Config.new(plugins: [observability]) +Rails.configuration.ld_client = LaunchDarkly::LDClient.new( ENV['LAUNCHDARKLY_SDK_KEY'], - LaunchDarkly::Config.new(plugins: [observability]) + config ) # Ensure clean shutdown -at_exit { $ld_client.close } +at_exit { Rails.configuration.ld_client.close } ``` Use in controllers: ```ruby class ApplicationController < ActionController::Base + private + + # Helper method for accessing the LaunchDarkly client + def ld_client + Rails.configuration.ld_client + end + def current_ld_context @current_ld_context ||= LaunchDarkly::LDContext.create({ key: current_user&.id || 'anonymous', @@ -107,7 +115,7 @@ end class HomeController < ApplicationController def index # This evaluation is automatically traced and correlated with the HTTP request - @show_new_feature = $ld_client.variation('new-feature', current_ld_context, false) + @show_new_feature = ld_client.variation('new-feature', current_ld_context, false) end end ``` From c8a93149d864db9af9af13bb9325f551220faefe Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 14:52:49 -0800 Subject: [PATCH 6/7] Update to match semantic conventions --- .../observability-ruby/README.md | 59 +++++++--- .../lib/launchdarkly_observability.rb | 28 ++++- .../lib/launchdarkly_observability/hook.rb | 110 +++++++++++++----- .../observability-ruby/test/hook_test.rb | 42 ++++--- .../test/integration_test.rb | 48 +++++--- 5 files changed, 211 insertions(+), 76 deletions(-) diff --git a/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md index 1a860e3ed..040bcc859 100644 --- a/sdk/@launchdarkly/observability-ruby/README.md +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -170,32 +170,59 @@ You can configure via environment variables: ### Span Attributes -Each flag evaluation creates a span with the following attributes: +Each flag evaluation creates a span with the following attributes, following [OpenTelemetry semantic conventions for feature flags](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/): + +### Standard Semantic Convention Attributes + +| Attribute | Status | Description | Example | +|-----------|--------|-------------|---------| +| `feature_flag.key` | Release Candidate | Flag key | `"my-feature"` | +| `feature_flag.provider.name` | Release Candidate | Provider name | `"LaunchDarkly"` | +| `feature_flag.result.value` | Release Candidate | Evaluated value | `"true"` | +| `feature_flag.result.variant` | Release Candidate | Variation index | `"1"` | +| `feature_flag.result.reason` | Release Candidate | Evaluation reason | `"default"`, `"targeting_match"`, `"error"` | +| `feature_flag.context.id` | Release Candidate | Context identifier | `"user-123"` | +| `error.type` | Stable | Error type (when applicable) | `"flag_not_found"` | +| `error.message` | Development | Error message (when applicable) | `"Flag evaluation error: FLAG_NOT_FOUND"` | + +### LaunchDarkly-Specific Attributes + +These custom attributes provide additional LaunchDarkly-specific details: | Attribute | Description | Example | |-----------|-------------|---------| -| `feature_flag.key` | Flag key | `"my-feature"` | -| `feature_flag.provider_name` | Provider name | `"LaunchDarkly"` | -| `feature_flag.value` | Evaluated value | `"true"` | -| `feature_flag.value.type` | Value type | `"TrueClass"` | -| `feature_flag.variant` | Variation index | `"1"` | -| `feature_flag.context.kind` | Context kind | `"user"` | -| `feature_flag.context.key` | Context key | `"user-123"` | -| `feature_flag.reason.kind` | Evaluation reason | `"FALLTHROUGH"` | -| `feature_flag.evaluation.duration_ms` | Evaluation time | `0.5` | -| `feature_flag.evaluation.method` | SDK method called | `"variation"` | +| `launchdarkly.context.kind` | Context kind | `"user"` | +| `launchdarkly.context.key` | Context key | `"user-123"` | +| `launchdarkly.reason.kind` | LaunchDarkly reason kind | `"FALLTHROUGH"`, `"RULE_MATCH"`, `"ERROR"` | +| `launchdarkly.reason.rule_index` | Rule index (for RULE_MATCH) | `0` | +| `launchdarkly.reason.rule_id` | Rule ID (for RULE_MATCH) | `"rule-key"` | +| `launchdarkly.reason.prerequisite_key` | Prerequisite key (for PREREQUISITE_FAILED) | `"other-flag"` | +| `launchdarkly.reason.in_experiment` | In experiment flag | `true` | +| `launchdarkly.reason.error_kind` | LaunchDarkly error kind (for ERROR) | `"FLAG_NOT_FOUND"` | +| `launchdarkly.evaluation.duration_ms` | Evaluation time in milliseconds | `0.5` | +| `launchdarkly.evaluation.method` | SDK method called | `"variation"`, `"variation_detail"` | ### Error Tracking -When evaluation errors occur, additional attributes are added: +When evaluation errors occur, the plugin follows [OpenTelemetry error semantic conventions](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/): -| Attribute | Description | Example | -|-----------|-------------|---------| -| `feature_flag.error` | Error kind | `"FLAG_NOT_FOUND"` | -| `feature_flag.reason.error_kind` | Detailed error | `"FLAG_NOT_FOUND"` | +- **`error.type`**: Mapped from LaunchDarkly error kinds to standard values (`flag_not_found`, `type_mismatch`, `provider_not_ready`, `general`) +- **`error.message`**: Human-readable error description +- **`feature_flag.result.reason`**: Set to `"error"` +- **`launchdarkly.reason.error_kind`**: Original LaunchDarkly error kind (`FLAG_NOT_FOUND`, `WRONG_TYPE`, etc.) The span status is also set to `ERROR` with a descriptive message. +#### Error Type Mapping + +| LaunchDarkly Error | OpenTelemetry `error.type` | +|-------------------|----------------------------| +| `FLAG_NOT_FOUND` | `flag_not_found` | +| `WRONG_TYPE` | `type_mismatch` | +| `CLIENT_NOT_READY` | `provider_not_ready` | +| `MALFORMED_FLAG` | `parse_error` | +| Others | `general` | + ### Rails Integration When used with Rails, the plugin provides: diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb index 8e3a5dbab..5dbddc65f 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability.rb @@ -25,10 +25,32 @@ module LaunchDarklyObservability DISTRO_NAME_ATTRIBUTE = 'telemetry.distro.name' DISTRO_VERSION_ATTRIBUTE = 'telemetry.distro.version' - # Semantic convention attribute keys for feature flags + # OpenTelemetry semantic convention attribute keys for feature flags + # See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ + + # Standard semantic conventions (Release Candidate) FEATURE_FLAG_KEY = 'feature_flag.key' - FEATURE_FLAG_VARIANT = 'feature_flag.variant' - FEATURE_FLAG_PROVIDER = 'feature_flag.provider_name' + FEATURE_FLAG_PROVIDER_NAME = 'feature_flag.provider.name' + FEATURE_FLAG_CONTEXT_ID = 'feature_flag.context.id' + FEATURE_FLAG_RESULT_VALUE = 'feature_flag.result.value' + FEATURE_FLAG_RESULT_VARIANT = 'feature_flag.result.variant' + FEATURE_FLAG_RESULT_REASON = 'feature_flag.result.reason' + FEATURE_FLAG_SET_ID = 'feature_flag.set.id' + FEATURE_FLAG_VERSION = 'feature_flag.version' + ERROR_TYPE = 'error.type' + ERROR_MESSAGE = 'error.message' + + # LaunchDarkly-specific custom attributes (not in OTel spec) + LD_EVALUATION_METHOD = 'launchdarkly.evaluation.method' + LD_EVALUATION_DURATION_MS = 'launchdarkly.evaluation.duration_ms' + LD_CONTEXT_KIND = 'launchdarkly.context.kind' + LD_CONTEXT_KEY = 'launchdarkly.context.key' + LD_REASON_KIND = 'launchdarkly.reason.kind' + LD_REASON_RULE_INDEX = 'launchdarkly.reason.rule_index' + LD_REASON_RULE_ID = 'launchdarkly.reason.rule_id' + LD_REASON_PREREQUISITE_KEY = 'launchdarkly.reason.prerequisite_key' + LD_REASON_IN_EXPERIMENT = 'launchdarkly.reason.in_experiment' + LD_REASON_ERROR_KIND = 'launchdarkly.reason.error_kind' class << self # @return [Plugin, nil] The current plugin instance diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb index 731c3e307..57f7330cc 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb @@ -25,8 +25,8 @@ class Hook # Tracer name for OpenTelemetry spans TRACER_NAME = 'launchdarkly-ruby' - # Span name prefix - SPAN_PREFIX = 'launchdarkly' + # Span name for feature flag evaluations + SPAN_NAME = 'evaluation' # Returns metadata about this hook # @@ -46,9 +46,8 @@ def before_evaluation(series_context, data) return data unless opentelemetry_available? tracer = OpenTelemetry.tracer_provider.tracer(TRACER_NAME, LaunchDarklyObservability::VERSION) - span_name = "#{SPAN_PREFIX}.#{series_context.method}" - span = tracer.start_span(span_name, attributes: build_before_attributes(series_context)) + span = tracer.start_span(SPAN_NAME, attributes: build_before_attributes(series_context)) data.merge( __ld_observability_span: span, @@ -80,7 +79,7 @@ def after_evaluation(series_context, data, detail) # Add duration if we have a start time if start_time duration_ms = ((monotonic_time - start_time) * 1000).round(3) - span.set_attribute('feature_flag.evaluation.duration_ms', duration_ms) + span.set_attribute(LD_EVALUATION_DURATION_MS, duration_ms) end # Handle errors @@ -109,21 +108,20 @@ def monotonic_time def build_before_attributes(series_context) attrs = { FEATURE_FLAG_KEY => series_context.key, - FEATURE_FLAG_PROVIDER => 'LaunchDarkly', - 'feature_flag.evaluation.method' => series_context.method.to_s + FEATURE_FLAG_PROVIDER_NAME => 'LaunchDarkly', + LD_EVALUATION_METHOD => series_context.method.to_s } # Add context information safely context = series_context.context if context - attrs['feature_flag.context.kind'] = extract_context_kind(context) - attrs['feature_flag.context.key'] = extract_context_key(context) + # Use semantic convention for context.id (the primary identifier) + attrs[FEATURE_FLAG_CONTEXT_ID] = extract_context_key(context) + # Use LaunchDarkly-specific attributes for additional context details + attrs[LD_CONTEXT_KIND] = extract_context_kind(context) + attrs[LD_CONTEXT_KEY] = extract_context_key(context) end - # Add default value type - default_value = series_context.default_value - attrs['feature_flag.default_value.type'] = default_value.class.name unless default_value.nil? - attrs end @@ -149,10 +147,12 @@ def extract_context_key(context) end def add_result_attributes(span, detail) - # Variation index (if available) - span.set_attribute(FEATURE_FLAG_VARIANT, detail.variation_index.to_s) if detail.variation_index + # Use semantic convention for result.variant (variation index as string) + if detail.variation_index + span.set_attribute(FEATURE_FLAG_RESULT_VARIANT, detail.variation_index.to_s) + end - # Value - convert to string for safe attribute value + # Use semantic convention for result.value value = detail.value value_str = case value when String, Numeric, TrueClass, FalseClass, NilClass @@ -162,14 +162,20 @@ def add_result_attributes(span, detail) else value.to_s end - span.set_attribute('feature_flag.value', value_str) - span.set_attribute('feature_flag.value.type', value.class.name) + span.set_attribute(FEATURE_FLAG_RESULT_VALUE, value_str) - # Evaluation reason + # Evaluation reason - use semantic convention reason = detail.reason return unless reason - span.set_attribute('feature_flag.reason.kind', reason.kind.to_s) if reason.respond_to?(:kind) + # Map LaunchDarkly reason.kind to semantic convention result.reason + if reason.respond_to?(:kind) + reason_value = map_reason_kind_to_semconv(reason.kind) + span.set_attribute(FEATURE_FLAG_RESULT_REASON, reason_value) + + # Also add LaunchDarkly-specific reason.kind for compatibility + span.set_attribute(LD_REASON_KIND, reason.kind.to_s) + end # Additional reason details based on kind add_reason_details(span, reason) @@ -178,30 +184,74 @@ def add_result_attributes(span, detail) def add_reason_details(span, reason) return unless reason.respond_to?(:kind) + # LaunchDarkly-specific reason details (custom attributes) case reason.kind when :RULE_MATCH - span.set_attribute('feature_flag.reason.rule_index', reason.rule_index) if reason.respond_to?(:rule_index) - span.set_attribute('feature_flag.reason.rule_id', reason.rule_id) if reason.respond_to?(:rule_id) + span.set_attribute(LD_REASON_RULE_INDEX, reason.rule_index) if reason.respond_to?(:rule_index) + span.set_attribute(LD_REASON_RULE_ID, reason.rule_id) if reason.respond_to?(:rule_id) when :PREREQUISITE_FAILED if reason.respond_to?(:prerequisite_key) - span.set_attribute('feature_flag.reason.prerequisite_key', - reason.prerequisite_key) + span.set_attribute(LD_REASON_PREREQUISITE_KEY, reason.prerequisite_key) end when :ERROR - span.set_attribute('feature_flag.reason.error_kind', reason.error_kind.to_s) if reason.respond_to?(:error_kind) + span.set_attribute(LD_REASON_ERROR_KIND, reason.error_kind.to_s) if reason.respond_to?(:error_kind) end - # In experiment flag - span.set_attribute('feature_flag.reason.in_experiment', reason.in_experiment) if reason.respond_to?(:in_experiment) + # In experiment flag (LaunchDarkly-specific) + if reason.respond_to?(:in_experiment) && !reason.in_experiment.nil? + span.set_attribute(LD_REASON_IN_EXPERIMENT, reason.in_experiment) + end + end + + # Map LaunchDarkly reason kinds to OpenTelemetry semantic convention values + # See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ + def map_reason_kind_to_semconv(kind) + case kind + when :OFF + 'disabled' + when :FALLTHROUGH + 'default' + when :TARGET_MATCH + 'targeting_match' + when :RULE_MATCH + 'targeting_match' + when :PREREQUISITE_FAILED + 'default' + when :ERROR + 'error' + else + 'unknown' + end end def handle_evaluation_error(span, detail) reason = detail.reason return unless reason&.respond_to?(:kind) && reason.kind == :ERROR - error_kind = reason.respond_to?(:error_kind) ? reason.error_kind.to_s : 'UNKNOWN' - span.set_attribute('feature_flag.error', error_kind) - span.status = OpenTelemetry::Trace::Status.error("Flag evaluation error: #{error_kind}") + # Use semantic convention for error.type + error_kind = reason.respond_to?(:error_kind) ? reason.error_kind.to_s : 'general' + + # Map LaunchDarkly error kinds to semantic convention values + error_type = case error_kind.upcase + when 'FLAG_NOT_FOUND' + 'flag_not_found' + when 'MALFORMED_FLAG' + 'parse_error' + when 'USER_NOT_SPECIFIED', 'CLIENT_NOT_READY' + 'provider_not_ready' + when 'WRONG_TYPE' + 'type_mismatch' + else + 'general' + end + + span.set_attribute(ERROR_TYPE, error_type) + + # Add human-readable error message + error_message = "Flag evaluation error: #{error_kind}" + span.set_attribute(ERROR_MESSAGE, error_message) + + span.status = OpenTelemetry::Trace::Status.error(error_message) end end end diff --git a/sdk/@launchdarkly/observability-ruby/test/hook_test.rb b/sdk/@launchdarkly/observability-ruby/test/hook_test.rb index f0483a3a4..831e3d63a 100644 --- a/sdk/@launchdarkly/observability-ruby/test/hook_test.rb +++ b/sdk/@launchdarkly/observability-ruby/test/hook_test.rb @@ -72,7 +72,7 @@ def test_after_evaluation_finishes_span assert_equal 1, spans.length span = spans.first - assert_equal 'launchdarkly.variation', span.name + assert_equal 'evaluation', span.name end def test_after_evaluation_adds_result_attributes @@ -85,9 +85,13 @@ def test_after_evaluation_adds_result_attributes spans = @exporter.finished_spans span = spans.first - assert_equal 'true', span.attributes['feature_flag.value'] - assert_equal '1', span.attributes['feature_flag.variant'] - assert_equal 'FALLTHROUGH', span.attributes['feature_flag.reason.kind'] + # Semantic convention attributes + assert_equal 'true', span.attributes['feature_flag.result.value'] + assert_equal '1', span.attributes['feature_flag.result.variant'] + assert_equal 'default', span.attributes['feature_flag.result.reason'] + + # LaunchDarkly-specific attributes + assert_equal 'FALLTHROUGH', span.attributes['launchdarkly.reason.kind'] end def test_after_evaluation_handles_error_reason @@ -100,8 +104,15 @@ def test_after_evaluation_handles_error_reason spans = @exporter.finished_spans span = spans.first - assert_equal 'ERROR', span.attributes['feature_flag.reason.kind'] - assert_equal 'FLAG_NOT_FOUND', span.attributes['feature_flag.error'] + # Semantic convention attributes + assert_equal 'error', span.attributes['feature_flag.result.reason'] + assert_equal 'flag_not_found', span.attributes['error.type'] + assert_includes span.attributes['error.message'], 'FLAG_NOT_FOUND' + + # LaunchDarkly-specific attributes + assert_equal 'ERROR', span.attributes['launchdarkly.reason.kind'] + assert_equal 'FLAG_NOT_FOUND', span.attributes['launchdarkly.reason.error_kind'] + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code end @@ -119,7 +130,7 @@ def test_after_evaluation_records_duration spans = @exporter.finished_spans span = spans.first - duration = span.attributes['feature_flag.evaluation.duration_ms'] + duration = span.attributes['launchdarkly.evaluation.duration_ms'] assert duration.positive?, "Duration should be positive, got #{duration}" end @@ -135,10 +146,14 @@ def test_before_evaluation_captures_context_info spans = @exporter.finished_spans span = spans.first + # Semantic convention attributes assert_equal 'my-flag', span.attributes['feature_flag.key'] - assert_equal 'user', span.attributes['feature_flag.context.kind'] - assert_equal 'user-456', span.attributes['feature_flag.context.key'] - assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider_name'] + assert_equal 'user-456', span.attributes['feature_flag.context.id'] + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider.name'] + + # LaunchDarkly-specific attributes + assert_equal 'user', span.attributes['launchdarkly.context.kind'] + assert_equal 'user-456', span.attributes['launchdarkly.context.key'] end def test_after_evaluation_handles_hash_value @@ -152,8 +167,7 @@ def test_after_evaluation_handles_hash_value span = spans.first # Hash should be JSON serialized - assert_includes span.attributes['feature_flag.value'], 'foo' - assert_equal 'Hash', span.attributes['feature_flag.value.type'] + assert_includes span.attributes['feature_flag.result.value'], 'foo' end def test_after_evaluation_handles_missing_span @@ -166,7 +180,7 @@ def test_after_evaluation_handles_missing_span assert_equal data, result end - def test_span_name_includes_method + def test_span_name_is_evaluation series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( 'flag', create_ld_context, false, :variation_detail ) @@ -175,6 +189,6 @@ def test_span_name_includes_method @hook.after_evaluation(series_context, data, create_evaluation_detail) spans = @exporter.finished_spans - assert_equal 'launchdarkly.variation_detail', spans.first.name + assert_equal 'evaluation', spans.first.name end end diff --git a/sdk/@launchdarkly/observability-ruby/test/integration_test.rb b/sdk/@launchdarkly/observability-ruby/test/integration_test.rb index 983343d65..731b83602 100644 --- a/sdk/@launchdarkly/observability-ruby/test/integration_test.rb +++ b/sdk/@launchdarkly/observability-ruby/test/integration_test.rb @@ -59,12 +59,18 @@ def test_full_evaluation_creates_span assert_equal 1, spans.length span = spans.first - assert_equal 'launchdarkly.variation', span.name + assert_equal 'evaluation', span.name + + # Semantic convention attributes assert_equal 'test-flag', span.attributes['feature_flag.key'] - assert_equal 'user', span.attributes['feature_flag.context.kind'] - assert_equal 'user-123', span.attributes['feature_flag.context.key'] - assert_equal 'true', span.attributes['feature_flag.value'] - assert_equal '1', span.attributes['feature_flag.variant'] + assert_equal 'user-123', span.attributes['feature_flag.context.id'] + assert_equal 'true', span.attributes['feature_flag.result.value'] + assert_equal '1', span.attributes['feature_flag.result.variant'] + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider.name'] + + # LaunchDarkly-specific attributes + assert_equal 'user', span.attributes['launchdarkly.context.kind'] + assert_equal 'user-123', span.attributes['launchdarkly.context.key'] end def test_variation_detail_creates_span_with_reason @@ -89,8 +95,13 @@ def test_variation_detail_creates_span_with_reason spans = @exporter.finished_spans span = spans.first - assert_equal 'launchdarkly.variation_detail', span.name - assert_equal 'FALLTHROUGH', span.attributes['feature_flag.reason.kind'] + assert_equal 'evaluation', span.name + + # Semantic convention attributes + assert_equal 'default', span.attributes['feature_flag.result.reason'] + + # LaunchDarkly-specific attributes + assert_equal 'FALLTHROUGH', span.attributes['launchdarkly.reason.kind'] end def test_error_evaluation_creates_error_span @@ -116,7 +127,13 @@ def test_error_evaluation_creates_error_span spans = @exporter.finished_spans span = spans.first - assert_equal 'FLAG_NOT_FOUND', span.attributes['feature_flag.error'] + # Semantic convention attributes + assert_equal 'flag_not_found', span.attributes['error.type'] + assert_includes span.attributes['error.message'], 'FLAG_NOT_FOUND' + + # LaunchDarkly-specific attributes + assert_equal 'FLAG_NOT_FOUND', span.attributes['launchdarkly.reason.error_kind'] + assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code end @@ -145,10 +162,16 @@ def test_multiple_evaluations_create_multiple_spans spans = @exporter.finished_spans assert_equal 3, spans.length + # All spans should have semantic convention attributes flag_keys = spans.map { |s| s.attributes['feature_flag.key'] } assert_includes flag_keys, 'flag-a' assert_includes flag_keys, 'flag-b' assert_includes flag_keys, 'flag-c' + + # Verify all spans have provider name + spans.each do |span| + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider.name'] + end end def test_multi_context_evaluation @@ -175,8 +198,8 @@ def test_multi_context_evaluation spans = @exporter.finished_spans span = spans.first - # Multi-context should have kinds joined - context_kind = span.attributes['feature_flag.context.kind'] + # Multi-context should have kinds joined (LaunchDarkly-specific) + context_kind = span.attributes['launchdarkly.context.kind'] assert_includes context_kind, 'user' assert_includes context_kind, 'organization' end @@ -204,9 +227,8 @@ def test_json_flag_value_serialization spans = @exporter.finished_spans span = spans.first - # JSON value should be serialized - assert_includes span.attributes['feature_flag.value'], 'feature' - assert_equal 'Hash', span.attributes['feature_flag.value.type'] + # JSON value should be serialized in result.value + assert_includes span.attributes['feature_flag.result.value'], 'feature' end def test_plugin_hook_registered_via_config From 34d6ba3c6b8b660087c124fb9a1e678c0f943e7e Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 4 Feb 2026 15:04:18 -0800 Subject: [PATCH 7/7] Add events --- .../observability-ruby/README.md | 31 ++ .../lib/launchdarkly_observability/hook.rb | 59 ++- .../test/attribute_naming_test.rb | 446 ++++++++++++++++++ 3 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 sdk/@launchdarkly/observability-ruby/test/attribute_naming_test.rb diff --git a/sdk/@launchdarkly/observability-ruby/README.md b/sdk/@launchdarkly/observability-ruby/README.md index 040bcc859..f67423d4f 100644 --- a/sdk/@launchdarkly/observability-ruby/README.md +++ b/sdk/@launchdarkly/observability-ruby/README.md @@ -168,6 +168,15 @@ You can configure via environment variables: ## Telemetry Details +### Cross-SDK Compatibility + +This Ruby SDK is designed for compatibility with other LaunchDarkly observability SDKs (Android, Node.js, Python, Go, .NET). Key compatibility features: + +- **Span name**: `"evaluation"` (consistent across all SDKs) +- **Event name**: `"feature_flag"` (matches Android and Node SDKs) +- **Provider name**: `"LaunchDarkly"` (consistent across all SDKs) +- **Attribute naming**: Follows OpenTelemetry semantic conventions + ### Span Attributes Each flag evaluation creates a span with the following attributes, following [OpenTelemetry semantic conventions for feature flags](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/): @@ -202,6 +211,28 @@ These custom attributes provide additional LaunchDarkly-specific details: | `launchdarkly.evaluation.duration_ms` | Evaluation time in milliseconds | `0.5` | | `launchdarkly.evaluation.method` | SDK method called | `"variation"`, `"variation_detail"` | +### Feature Flag Event + +In addition to span attributes, each evaluation adds a `"feature_flag"` event to the span. This matches the pattern used by other LaunchDarkly observability SDKs (Android, Node.js) and follows OpenTelemetry semantic conventions for feature flag events. + +The event contains the core evaluation data: + +| Event Attribute | Description | Example | +|-----------------|-------------|---------| +| `feature_flag.key` | Flag key | `"my-feature"` | +| `feature_flag.provider.name` | Provider name | `"LaunchDarkly"` | +| `feature_flag.context.id` | Context identifier | `"user-123"` | +| `feature_flag.result.value` | Evaluated value | `"true"` | +| `feature_flag.result.variant` | Variation index | `"1"` | +| `feature_flag.result.reason` | Evaluation reason | `"default"` | +| `launchdarkly.reason.in_experiment` | In experiment flag (if applicable) | `true` | + +**Why both span attributes and events?** + +- **Span attributes** provide detailed context for the entire evaluation span, including timing, method, and LaunchDarkly-specific details +- **Span events** represent a point-in-time record of the evaluation result, which is the standard OpenTelemetry pattern for feature flag evaluations +- This dual approach matches other LaunchDarkly SDKs and maximizes compatibility with observability backends + ### Error Tracking When evaluation errors occur, the plugin follows [OpenTelemetry error semantic conventions](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/): diff --git a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb index 57f7330cc..c115e3159 100644 --- a/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb +++ b/sdk/@launchdarkly/observability-ruby/lib/launchdarkly_observability/hook.rb @@ -3,7 +3,7 @@ require 'launchdarkly-server-sdk' module LaunchDarklyObservability - # Evaluation hook that instruments LaunchDarkly flag evaluations with OpenTelemetry spans. + # Evaluation hook that instruments LaunchDarkly flag evaluations with OpenTelemetry spans and events. # # This hook creates spans for each flag evaluation, capturing: # - Flag key and evaluation method @@ -11,6 +11,10 @@ module LaunchDarklyObservability # - Evaluation result (value, variation index, reason) # - Duration and any errors # + # Additionally, a "feature_flag" event is added to the span with evaluation results, + # following the OpenTelemetry semantic conventions for feature flags and matching + # the pattern used by other LaunchDarkly observability SDKs (Android, Node). + # # @example The hook is automatically registered when using the Plugin # plugin = LaunchDarklyObservability::Plugin.new(project_id: 'my-project') # config = LaunchDarkly::Config.new(plugins: [plugin]) @@ -28,6 +32,9 @@ class Hook # Span name for feature flag evaluations SPAN_NAME = 'evaluation' + # Event name for feature flag evaluation results (matches Android/Node SDKs) + FEATURE_FLAG_EVENT_NAME = 'feature_flag' + # Returns metadata about this hook # # @return [LaunchDarkly::Interfaces::Hooks::Metadata] @@ -62,6 +69,8 @@ def before_evaluation(series_context, data) # Called after flag evaluation # # Completes the span with evaluation results and timing information. + # Also adds a "feature_flag" event with evaluation results, matching + # the pattern used by Android and Node SDKs. # # @param series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext] # @param data [Hash] Data passed between hook stages @@ -73,7 +82,7 @@ def after_evaluation(series_context, data, detail) start_time = data[:__ld_observability_start_time] - # Add result attributes + # Add result attributes to span add_result_attributes(span, detail) # Add duration if we have a start time @@ -85,6 +94,9 @@ def after_evaluation(series_context, data, detail) # Handle errors handle_evaluation_error(span, detail) + # Add feature_flag event with evaluation results (matches Android/Node SDKs) + add_feature_flag_event(span, series_context, detail) + span.finish data @@ -202,7 +214,48 @@ def add_reason_details(span, reason) span.set_attribute(LD_REASON_IN_EXPERIMENT, reason.in_experiment) end end - + + # Add a "feature_flag" event with evaluation results + # This matches the pattern used by Android and Node SDKs for cross-SDK consistency + # See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ + def add_feature_flag_event(span, series_context, detail) + event_attributes = { + FEATURE_FLAG_KEY => series_context.key, + FEATURE_FLAG_PROVIDER_NAME => 'LaunchDarkly', + FEATURE_FLAG_CONTEXT_ID => extract_context_key(series_context.context) + } + + # Add variation index if available + if detail.variation_index + event_attributes[FEATURE_FLAG_RESULT_VARIANT] = detail.variation_index.to_s + end + + # Add value (serialized to string for complex types) + value = detail.value + value_str = case value + when String, Numeric, TrueClass, FalseClass, NilClass + value.to_s + when Hash, Array + value.to_json + else + value.to_s + end + event_attributes[FEATURE_FLAG_RESULT_VALUE] = value_str + + # Add reason if available + reason = detail.reason + if reason&.respond_to?(:kind) + event_attributes[FEATURE_FLAG_RESULT_REASON] = map_reason_kind_to_semconv(reason.kind) + + # Add in_experiment flag if present (matches Android SDK) + if reason.respond_to?(:in_experiment) && !reason.in_experiment.nil? + event_attributes[LD_REASON_IN_EXPERIMENT] = reason.in_experiment + end + end + + span.add_event(FEATURE_FLAG_EVENT_NAME, attributes: event_attributes) + end + # Map LaunchDarkly reason kinds to OpenTelemetry semantic convention values # See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ def map_reason_kind_to_semconv(kind) diff --git a/sdk/@launchdarkly/observability-ruby/test/attribute_naming_test.rb b/sdk/@launchdarkly/observability-ruby/test/attribute_naming_test.rb new file mode 100644 index 000000000..2b54cfc1a --- /dev/null +++ b/sdk/@launchdarkly/observability-ruby/test/attribute_naming_test.rb @@ -0,0 +1,446 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Tests to verify attribute naming consistency with OpenTelemetry semantic conventions +# and cross-SDK compatibility. +# +# OpenTelemetry Feature Flag Semantic Conventions: +# https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ +# +class AttributeNamingTest < Minitest::Test + include TestHelper + + def setup + @hook = LaunchDarklyObservability::Hook.new + @exporter = create_test_exporter + + OpenTelemetry::SDK.configure do |c| + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@exporter) + ) + end + end + + def teardown + @exporter.reset + end + + # OpenTelemetry Semantic Convention Attribute Tests + # These attributes should match the OTel spec exactly + + def test_feature_flag_key_follows_semconv + # OTel semconv: feature_flag.key + series_context = create_series_context(key: 'test-flag') + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.key'), 'Missing feature_flag.key attribute' + assert_equal 'test-flag', span.attributes['feature_flag.key'] + end + + def test_feature_flag_provider_name_follows_semconv + # OTel semconv: feature_flag.provider.name + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.provider.name'), 'Missing feature_flag.provider.name attribute' + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider.name'] + end + + def test_feature_flag_context_id_follows_semconv + # OTel semconv: feature_flag.context.id + context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'flag', context, false, :variation + ) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.context.id'), 'Missing feature_flag.context.id attribute' + assert_equal 'user-123', span.attributes['feature_flag.context.id'] + end + + def test_feature_flag_result_value_follows_semconv + # OTel semconv: feature_flag.result.value + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail(value: 'test-value')) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.result.value'), 'Missing feature_flag.result.value attribute' + assert_equal 'test-value', span.attributes['feature_flag.result.value'] + end + + def test_feature_flag_result_variant_follows_semconv + # OTel semconv: feature_flag.result.variant + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail(variation_index: 2)) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.result.variant'), 'Missing feature_flag.result.variant attribute' + assert_equal '2', span.attributes['feature_flag.result.variant'] + end + + def test_feature_flag_result_reason_follows_semconv + # OTel semconv: feature_flag.result.reason + # Maps LaunchDarkly reason.kind to semconv values + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert span.attributes.key?('feature_flag.result.reason'), 'Missing feature_flag.result.reason attribute' + # FALLTHROUGH maps to 'default' per semconv + assert_equal 'default', span.attributes['feature_flag.result.reason'] + end + + def test_error_type_follows_semconv + # OTel semconv: error.type + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_error_detail(error_kind: :FLAG_NOT_FOUND)) + + span = @exporter.finished_spans.first + assert span.attributes.key?('error.type'), 'Missing error.type attribute' + assert_equal 'flag_not_found', span.attributes['error.type'] + end + + def test_error_message_follows_semconv + # OTel semconv: error.message + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_error_detail(error_kind: :FLAG_NOT_FOUND)) + + span = @exporter.finished_spans.first + assert span.attributes.key?('error.message'), 'Missing error.message attribute' + assert_includes span.attributes['error.message'], 'FLAG_NOT_FOUND' + end + + # LaunchDarkly-specific Attribute Tests + # These should use the 'launchdarkly.*' namespace + + def test_launchdarkly_namespace_for_custom_attributes + context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'flag', context, false, :variation + ) + + data = @hook.before_evaluation(series_context, {}) + sleep(0.001) # Ensure measurable duration + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + + # All LaunchDarkly-specific attributes should use launchdarkly.* namespace + ld_attributes = span.attributes.keys.select { |k| k.start_with?('launchdarkly.') } + + assert_includes ld_attributes, 'launchdarkly.evaluation.method' + assert_includes ld_attributes, 'launchdarkly.evaluation.duration_ms' + assert_includes ld_attributes, 'launchdarkly.context.kind' + assert_includes ld_attributes, 'launchdarkly.context.key' + assert_includes ld_attributes, 'launchdarkly.reason.kind' + end + + def test_launchdarkly_reason_kind_attribute + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + # LaunchDarkly-specific reason kind (raw value from SDK) + assert span.attributes.key?('launchdarkly.reason.kind'), 'Missing launchdarkly.reason.kind attribute' + assert_equal 'FALLTHROUGH', span.attributes['launchdarkly.reason.kind'] + end + + def test_launchdarkly_context_kind_attribute + context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'flag', context, false, :variation + ) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert span.attributes.key?('launchdarkly.context.kind'), 'Missing launchdarkly.context.kind attribute' + assert_equal 'user', span.attributes['launchdarkly.context.kind'] + end + + def test_launchdarkly_error_attributes + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_error_detail(error_kind: :FLAG_NOT_FOUND)) + + span = @exporter.finished_spans.first + # LaunchDarkly-specific error kind + assert span.attributes.key?('launchdarkly.reason.error_kind'), 'Missing launchdarkly.reason.error_kind attribute' + assert_equal 'FLAG_NOT_FOUND', span.attributes['launchdarkly.reason.error_kind'] + end + + # Reason Mapping Tests + # Verify LaunchDarkly reason kinds map to correct semconv values + + def test_reason_mapping_off_to_disabled + reason = LaunchDarkly::EvaluationReason.off + detail = LaunchDarkly::EvaluationDetail.new(false, 0, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'disabled', span.attributes['feature_flag.result.reason'] + assert_equal 'OFF', span.attributes['launchdarkly.reason.kind'] + end + + def test_reason_mapping_fallthrough_to_default + reason = LaunchDarkly::EvaluationReason.fallthrough + detail = LaunchDarkly::EvaluationDetail.new(true, 1, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'default', span.attributes['feature_flag.result.reason'] + assert_equal 'FALLTHROUGH', span.attributes['launchdarkly.reason.kind'] + end + + def test_reason_mapping_target_match_to_targeting_match + reason = LaunchDarkly::EvaluationReason.target_match + detail = LaunchDarkly::EvaluationDetail.new(true, 1, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'targeting_match', span.attributes['feature_flag.result.reason'] + assert_equal 'TARGET_MATCH', span.attributes['launchdarkly.reason.kind'] + end + + def test_reason_mapping_error_to_error + reason = LaunchDarkly::EvaluationReason.error(:FLAG_NOT_FOUND) + detail = LaunchDarkly::EvaluationDetail.new(nil, nil, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'error', span.attributes['feature_flag.result.reason'] + assert_equal 'ERROR', span.attributes['launchdarkly.reason.kind'] + end + + # Error Type Mapping Tests + # Verify LaunchDarkly error kinds map to correct semconv error types + + def test_error_type_mapping_flag_not_found + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_error_detail(error_kind: :FLAG_NOT_FOUND)) + + span = @exporter.finished_spans.first + assert_equal 'flag_not_found', span.attributes['error.type'] + end + + def test_error_type_mapping_malformed_flag + reason = LaunchDarkly::EvaluationReason.error(:MALFORMED_FLAG) + detail = LaunchDarkly::EvaluationDetail.new(nil, nil, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'parse_error', span.attributes['error.type'] + end + + def test_error_type_mapping_client_not_ready + reason = LaunchDarkly::EvaluationReason.error(:CLIENT_NOT_READY) + detail = LaunchDarkly::EvaluationDetail.new(nil, nil, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'provider_not_ready', span.attributes['error.type'] + end + + def test_error_type_mapping_wrong_type + reason = LaunchDarkly::EvaluationReason.error(:WRONG_TYPE) + detail = LaunchDarkly::EvaluationDetail.new(nil, nil, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + assert_equal 'type_mismatch', span.attributes['error.type'] + end + + # Constants Verification Tests + # Verify constant definitions match expected values + + def test_module_constants_match_semconv_spec + # Standard OTel semconv attributes + assert_equal 'feature_flag.key', LaunchDarklyObservability::FEATURE_FLAG_KEY + assert_equal 'feature_flag.provider.name', LaunchDarklyObservability::FEATURE_FLAG_PROVIDER_NAME + assert_equal 'feature_flag.context.id', LaunchDarklyObservability::FEATURE_FLAG_CONTEXT_ID + assert_equal 'feature_flag.result.value', LaunchDarklyObservability::FEATURE_FLAG_RESULT_VALUE + assert_equal 'feature_flag.result.variant', LaunchDarklyObservability::FEATURE_FLAG_RESULT_VARIANT + assert_equal 'feature_flag.result.reason', LaunchDarklyObservability::FEATURE_FLAG_RESULT_REASON + assert_equal 'error.type', LaunchDarklyObservability::ERROR_TYPE + assert_equal 'error.message', LaunchDarklyObservability::ERROR_MESSAGE + end + + def test_launchdarkly_constants_use_correct_namespace + # LaunchDarkly-specific attributes should use launchdarkly.* namespace + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_EVALUATION_METHOD) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_EVALUATION_DURATION_MS) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_CONTEXT_KIND) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_CONTEXT_KEY) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_KIND) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_RULE_INDEX) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_RULE_ID) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_PREREQUISITE_KEY) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_IN_EXPERIMENT) + assert_match(/^launchdarkly\./, LaunchDarklyObservability::LD_REASON_ERROR_KIND) + end + + # Cross-SDK Compatibility Tests + # Verify attribute names match other LaunchDarkly observability SDKs + + def test_span_name_matches_other_sdks + # Android, Go, Node all use 'evaluation' as span name + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert_equal 'evaluation', span.name, 'Span name should be "evaluation" to match other SDKs' + end + + def test_provider_name_matches_other_sdks + # All SDKs should report 'LaunchDarkly' as provider name + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + assert_equal 'LaunchDarkly', span.attributes['feature_flag.provider.name'] + end + + # Feature Flag Event Tests + # Verify the "feature_flag" event is added to spans (matching Android/Node SDKs) + + def test_feature_flag_event_is_added_to_span + series_context = create_series_context(key: 'test-flag') + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, create_evaluation_detail) + + span = @exporter.finished_spans.first + events = span.events + + assert_equal 1, events.length, 'Expected one feature_flag event' + assert_equal 'feature_flag', events.first.name + end + + def test_feature_flag_event_contains_required_attributes + context = LaunchDarkly::LDContext.create({ key: 'user-123', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'my-flag', context, false, :variation + ) + detail = create_evaluation_detail(value: true, variation_index: 1) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + event = span.events.first + event_attrs = event.attributes + + # Required semantic convention attributes + assert_equal 'my-flag', event_attrs['feature_flag.key'] + assert_equal 'LaunchDarkly', event_attrs['feature_flag.provider.name'] + assert_equal 'user-123', event_attrs['feature_flag.context.id'] + assert_equal 'true', event_attrs['feature_flag.result.value'] + assert_equal '1', event_attrs['feature_flag.result.variant'] + assert_equal 'default', event_attrs['feature_flag.result.reason'] + end + + def test_feature_flag_event_includes_in_experiment_when_present + # Create a reason with in_experiment flag + reason = LaunchDarkly::EvaluationReason.fallthrough(true) # in_experiment = true + detail = LaunchDarkly::EvaluationDetail.new(true, 1, reason) + + series_context = create_series_context + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + event = span.events.first + event_attrs = event.attributes + + assert event_attrs.key?('launchdarkly.reason.in_experiment'), + 'Event should include in_experiment attribute' + assert_equal true, event_attrs['launchdarkly.reason.in_experiment'] + end + + def test_feature_flag_event_handles_complex_values + series_context = create_series_context + detail = create_evaluation_detail(value: { nested: { key: 'value' }, array: [1, 2, 3] }) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + event = span.events.first + event_attrs = event.attributes + + # Complex value should be JSON serialized + assert_includes event_attrs['feature_flag.result.value'], 'nested' + assert_includes event_attrs['feature_flag.result.value'], 'array' + end + + def test_feature_flag_event_matches_android_sdk_pattern + # Android SDK adds event named "feature_flag" with these attributes: + # - feature_flag.key + # - feature_flag.provider.name + # - feature_flag.context.id + # - feature_flag.result.value (if includeValue) + # - feature_flag.result.variationIndex + # - feature_flag.result.reason.inExperiment (if present) + + context = LaunchDarkly::LDContext.create({ key: 'user-456', kind: 'user' }) + series_context = LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext.new( + 'android-compat-flag', context, false, :variation + ) + detail = create_evaluation_detail(value: 'enabled', variation_index: 2) + + data = @hook.before_evaluation(series_context, {}) + @hook.after_evaluation(series_context, data, detail) + + span = @exporter.finished_spans.first + event = span.events.first + + # Event name matches Android + assert_equal 'feature_flag', event.name + + # Required attributes match Android pattern + event_attrs = event.attributes + assert event_attrs.key?('feature_flag.key') + assert event_attrs.key?('feature_flag.provider.name') + assert event_attrs.key?('feature_flag.context.id') + assert event_attrs.key?('feature_flag.result.value') + assert event_attrs.key?('feature_flag.result.variant') + end +end