diff --git a/.ruby-version b/.ruby-version index 4f5e697..7921bd0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.5 +3.4.8 diff --git a/.tool-versions b/.tool-versions index 27a8619..5876619 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.4.5 +ruby 3.4.8 diff --git a/Gemfile.lock b/Gemfile.lock index ae0f0c3..bb136ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,20 +2,20 @@ PATH remote: . specs: opentelemetry-instrumentation-servactory (0.1.0) - opentelemetry-api (~> 1.6.0) - opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (~> 0.22) GEM remote: https://rubygems.org/ specs: - activesupport (8.0.2.1) + activesupport (8.1.2) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) @@ -27,52 +27,59 @@ GEM thor (>= 0.14.0) ast (2.4.3) base64 (0.3.0) - benchmark (0.4.1) - bigdecimal (3.2.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) + bigdecimal (4.0.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) diff-lcs (1.6.2) drb (2.2.3) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.13.2) + json (2.18.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - minitest (5.25.5) - opentelemetry-api (1.6.0) - opentelemetry-common (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (0.23.0) + minitest (6.0.1) + prism (~> 1.5) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-base (0.25.0) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.10.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.36.0) + opentelemetry-api (~> 1.0) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - prism (1.4.0) + prism (1.9.0) racc (1.8.1) - rack (3.2.0) + rack (3.2.5) rainbow (3.1.1) - rake (13.3.0) - regexp_parser (2.11.2) - rspec (3.13.1) + rake (13.3.1) + regexp_parser (2.11.3) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.5) - rubocop (1.80.0) + rspec-support (3.13.7) + rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -80,20 +87,20 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.46.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.46.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-factory_bot (2.27.1) + prism (~> 1.7) + rubocop-factory_bot (2.28.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.33.3) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -102,15 +109,20 @@ GEM rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-rspec (3.6.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) - rubocop-rspec_rails (2.31.0) + rubocop (~> 1.81) + rubocop-rspec_rails (2.32.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) ruby-progressbar (1.13.0) securerandom (0.4.1) + servactory (3.0.2) + activesupport (>= 5.1) + i18n (>= 1.14) + stroma (>= 0.5, < 1.0) + zeitwerk (>= 2.6) servactory-rubocop (0.9.0) rubocop (>= 1.73) rubocop-factory_bot (>= 2.27) @@ -119,13 +131,16 @@ GEM rubocop-rake (>= 0.7) rubocop-rspec (>= 3.5) rubocop-rspec_rails (>= 2.31) - thor (1.4.0) + stroma (0.5.0) + zeitwerk (>= 2.6) + thor (1.5.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.5) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + zeitwerk (2.7.5) PLATFORMS aarch64-linux-gnu @@ -143,8 +158,10 @@ PLATFORMS DEPENDENCIES appraisal (>= 2.5) opentelemetry-instrumentation-servactory! + opentelemetry-sdk (~> 1.4) rspec (>= 3.13) + servactory (>= 2.16.0) servactory-rubocop (>= 0.9) BUNDLED WITH - 2.7.1 + 4.0.6 diff --git a/README.md b/README.md index 3c15fab..a4d30bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenTelemetry Servactory Instrumentation -Todo: Add a description. +Instrumentation for the [Servactory][servactory-home] service object framework. Automatically traces `call`/`call!` invocations and individual actions with span attributes, failure detection, and error recording. ## How do I get started? @@ -10,7 +10,18 @@ Install the gem using: gem install opentelemetry-instrumentation-servactory ``` -Or, if you use [bundler][bundler-home], include `opentelemetry-instrumentation-servactory` in your `Gemfile`. +Or, if you use [bundler][bundler-home], include `opentelemetry-instrumentation-servactory` in your `Gemfile`: + +```ruby +gem "opentelemetry-instrumentation-servactory" +``` + +## Requirements + +| Requirement | Version | +| --- | --- | +| Servactory | `>= 2.16.0` | +| Ruby | `>= 3.2` | ## Usage @@ -18,11 +29,11 @@ To use the instrumentation, call `use` with the name of the instrumentation: ```ruby OpenTelemetry::SDK.configure do |c| - c.use 'OpenTelemetry::Instrumentation::Servactory' + c.use("OpenTelemetry::Instrumentation::Servactory") end ``` -Alternatively, you can also call `use_all` to install all the available instrumentation. +Alternatively, you can also call `use_all` to install all the available instrumentation: ```ruby OpenTelemetry::SDK.configure do |c| @@ -30,13 +41,63 @@ OpenTelemetry::SDK.configure do |c| end ``` -## Examples +## Configuration Options + +| Option | Default | Type | Description | +| --- | --- | --- | --- | +| `trace_actions` | `true` | Boolean | Create child spans for each `make` action | +| `record_input_names` | `true` | Boolean | Record input names as a span attribute | +| `record_output_names` | `true` | Boolean | Record output names as a span attribute | + +Example with custom configuration: + +```ruby +OpenTelemetry::SDK.configure do |c| + c.use( + "OpenTelemetry::Instrumentation::Servactory", { + trace_actions: true, + record_input_names: false, + record_output_names: false + } + ) +end +``` + +## Span Structure + +Each `call`/`call!` invocation creates a root span. When `trace_actions` is enabled, each `make` action creates a child span. + +``` +Users::CreateService call (root span) + |-- Users::CreateService validate (child span per action) + |-- Users::CreateService create_user + |-- Users::CreateService send_email +``` + +### Span Attributes + +| Attribute | Type | Description | +| --- | --- | --- | +| `code.namespace` | String | Service class name | +| `code.function` | String | Method name (`call`, `call!`, or action name) | +| `servactory.version` | String | Servactory library version | +| `servactory.result` | String | `success`, `failure`, or `error` | +| `servactory.input_names` | Array | Input attribute names (when `record_input_names` is enabled) | +| `servactory.output_names` | Array | Output attribute names (when `record_output_names` is enabled) | + +### Failure Events + +When a service fails via `fail!`, a `servactory.failure` event is added to the span with: -Example usage can be seen in the [`./example/trace_demonstration.rb` file](https://github.com/servactory/opentelemetry-instrumentation-servactory/blob/main/example/trace_demonstration.rb) +| Attribute | Type | Description | +| --- | --- | --- | +| `servactory.failure.type` | String | Failure type (if provided) | +| `servactory.failure.message` | String | Failure message | ## License The `opentelemetry-instrumentation-servactory` gem is distributed under the MIT license. See [LICENSE][license-github] for more information. +[servactory-home]: https://servactory.com [bundler-home]: https://bundler.io [license-github]: https://github.com/servactory/opentelemetry-instrumentation-servactory/blob/main/LICENSE diff --git a/lib/opentelemetry/instrumentation/servactory/instrumentation.rb b/lib/opentelemetry/instrumentation/servactory/instrumentation.rb index ef53bf8..94ca370 100644 --- a/lib/opentelemetry/instrumentation/servactory/instrumentation.rb +++ b/lib/opentelemetry/instrumentation/servactory/instrumentation.rb @@ -4,20 +4,42 @@ module OpenTelemetry module Instrumentation module Servactory class Instrumentation < OpenTelemetry::Instrumentation::Base + instrumentation_version VERSION::STRING + + MINIMUM_VERSION = Gem::Version.new("2.16.0") + install do |_config| require_dependencies + patch_callable + patch_runner if config[:trace_actions] end present do - # TODO: Replace true with a definition check of the gem being instrumented - # Example: `defined?(::Rack)` - true + defined?(::Servactory) end + compatible do + gem_version = Gem::Version.new(::Servactory::VERSION::STRING) + gem_version >= MINIMUM_VERSION + end + + option :trace_actions, default: true, validate: :boolean + option :record_input_names, default: true, validate: :boolean + option :record_output_names, default: true, validate: :boolean + private def require_dependencies - # TODO: Include instrumentation dependencies + require_relative "patches/callable" + require_relative "patches/runner" + end + + def patch_callable + ::Servactory::Context::Callable.prepend(Patches::Callable) + end + + def patch_runner + ::Servactory::Actions::Tools::Runner.prepend(Patches::Runner) end end end diff --git a/lib/opentelemetry/instrumentation/servactory/patches/callable.rb b/lib/opentelemetry/instrumentation/servactory/patches/callable.rb new file mode 100644 index 0000000..a3c477e --- /dev/null +++ b/lib/opentelemetry/instrumentation/servactory/patches/callable.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module OpenTelemetry + module Instrumentation + module Servactory + module Patches + module Callable # rubocop:disable Metrics/ModuleLength + def call(...) # rubocop:disable Metrics/MethodLength + service_name = name || "AnonymousService" + attributes = _otel_build_span_attributes(service_name, "call") + + span = _otel_tracer.start_span("#{service_name} call", attributes:, kind: :internal) + OpenTelemetry::Trace.with_span(span) do + result = super + _otel_record_result(span, result) + result + rescue StandardError => e + _otel_record_exception_on_span(span, e) + raise + ensure + span.finish + end + end + + def call!(...) # rubocop:disable Metrics/MethodLength + service_name = name || "AnonymousService" + attributes = _otel_build_span_attributes(service_name, "call!") + + span = _otel_tracer.start_span("#{service_name} call!", attributes:, kind: :internal) + OpenTelemetry::Trace.with_span(span) do + result = super + _otel_mark_span_success(span) + result + rescue StandardError => e + _otel_record_exception_on_span(span, e) + raise + ensure + span.finish + end + end + + private + + def _otel_build_span_attributes(service_name, method_name) + attributes = { + "code.namespace" => service_name, + "code.function" => method_name, + "servactory.version" => ::Servactory::VERSION::STRING + } + _otel_append_attribute_names(attributes) + attributes + end + + def _otel_append_attribute_names(attributes) + config = OpenTelemetry::Instrumentation::Servactory::Instrumentation.instance.config + + if config[:record_input_names] + names = _otel_input_names + attributes["servactory.input_names"] = names unless names.empty? + end + + return unless config[:record_output_names] + + names = _otel_output_names + attributes["servactory.output_names"] = names unless names.empty? + end + + def _otel_input_names + send(:collection_of_inputs).names.map(&:to_s) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e, message: "Failed to collect Servactory input names") + [] + end + + def _otel_output_names + send(:collection_of_outputs).names.map(&:to_s) + rescue StandardError => e + OpenTelemetry.handle_error(exception: e, message: "Failed to collect Servactory output names") + [] + end + + def _otel_record_result(span, result) + if result.respond_to?(:failure?) && result.failure? + _otel_record_failure_on_span(span, result) + span.set_attribute("servactory.result", "failure") + else + _otel_mark_span_success(span) + end + end + + def _otel_mark_span_success(span) + span.set_attribute("servactory.result", "success") + span.status = OpenTelemetry::Trace::Status.ok + end + + def _otel_record_failure_on_span(span, result) + error = result.respond_to?(:error) ? result.error : nil + message = _otel_error_message_for(error) + + span.add_event("servactory.failure", attributes: _otel_failure_attributes(error)) + span.status = OpenTelemetry::Trace::Status.error(message) + end + + def _otel_record_exception_on_span(span, exception) + span.record_exception(exception) + + if exception.respond_to?(:type) + span.set_attribute("servactory.result", "failure") + span.status = OpenTelemetry::Trace::Status.error(_otel_error_message_for(exception)) + else + span.set_attribute("servactory.result", "error") + span.status = OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{exception.class}") + end + end + + def _otel_error_message_for(error) + return "Service failure" unless error + + if error.respond_to?(:message) + error.message.to_s + else + error.to_s + end + end + + def _otel_failure_attributes(error) + attrs = {} + attrs["servactory.failure.type"] = error.type.to_s if error.respond_to?(:type) && error.type + attrs["servactory.failure.message"] = error.message.to_s if error.respond_to?(:message) && error.message + attrs + end + + def _otel_tracer + OpenTelemetry::Instrumentation::Servactory::Instrumentation.instance.tracer + end + end + end + end + end +end diff --git a/lib/opentelemetry/instrumentation/servactory/patches/runner.rb b/lib/opentelemetry/instrumentation/servactory/patches/runner.rb new file mode 100644 index 0000000..8b21ace --- /dev/null +++ b/lib/opentelemetry/instrumentation/servactory/patches/runner.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module OpenTelemetry + module Instrumentation + module Servactory + module Patches + module Runner + private + + def call_action(action) + span = _otel_start_action_span(action) + OpenTelemetry::Trace.with_span(span) do + super + rescue StandardError => e + span.record_exception(e) + span.status = OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{e.class}") + raise + ensure + span.finish + end + end + + def _otel_start_action_span(action) + service_name = @context&.class&.name || "AnonymousService" + attributes = _otel_build_action_attributes(service_name, action.name.to_s) + + _otel_tracer.start_span("#{service_name} #{action.name}", attributes:, kind: :internal) + end + + def _otel_build_action_attributes(service_name, action_name) + { + "code.namespace" => service_name, + "code.function" => action_name, + "servactory.version" => ::Servactory::VERSION::STRING + } + end + + def _otel_tracer + OpenTelemetry::Instrumentation::Servactory::Instrumentation.instance.tracer + end + end + end + end + end +end diff --git a/opentelemetry-instrumentation-servactory.gemspec b/opentelemetry-instrumentation-servactory.gemspec index 8d8acbf..edbb1af 100644 --- a/opentelemetry-instrumentation-servactory.gemspec +++ b/opentelemetry-instrumentation-servactory.gemspec @@ -29,10 +29,12 @@ Gem::Specification.new do |spec| spec.required_ruby_version = Gem::Requirement.new(">= 3.2") - spec.add_dependency "opentelemetry-api", "~> 1.6.0" - spec.add_dependency "opentelemetry-instrumentation-base", "~> 0.23.0" + spec.add_dependency "opentelemetry-api", "~> 1.0" + spec.add_dependency "opentelemetry-instrumentation-base", "~> 0.22" spec.add_development_dependency "appraisal", ">= 2.5" + spec.add_development_dependency "opentelemetry-sdk", "~> 1.4" spec.add_development_dependency "rspec", ">= 3.13" + spec.add_development_dependency "servactory", ">= 2.16.0" spec.add_development_dependency "servactory-rubocop", ">= 0.9" end diff --git a/spec/opentelemetry/instrumentation/servactory/instrumentation_spec.rb b/spec/opentelemetry/instrumentation/servactory/instrumentation_spec.rb new file mode 100644 index 0000000..1c24291 --- /dev/null +++ b/spec/opentelemetry/instrumentation/servactory/instrumentation_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe OpenTelemetry::Instrumentation::Servactory::Instrumentation do + subject(:instrumentation) { described_class.instance } + + it "has a name" do + expect(instrumentation.name) + .to eq("OpenTelemetry::Instrumentation::Servactory") + end + + it "has a version" do + expect(instrumentation.version) + .to eq(OpenTelemetry::Instrumentation::Servactory::VERSION::STRING) + end + + describe "#present?" do + it { expect(instrumentation).to be_present } + end + + describe "#compatible?" do + it { expect(instrumentation.compatible?).to be(true) } + end + + describe "#install" do + before { instrumentation.install } + + it "sets default config options", :aggregate_failures do + expect(instrumentation.config[:trace_actions]) + .to be(true) + expect(instrumentation.config[:record_input_names]) + .to be(true) + expect(instrumentation.config[:record_output_names]) + .to be(true) + end + end +end diff --git a/spec/opentelemetry/instrumentation/servactory/patches/callable_spec.rb b/spec/opentelemetry/instrumentation/servactory/patches/callable_spec.rb new file mode 100644 index 0000000..fd423b4 --- /dev/null +++ b/spec/opentelemetry/instrumentation/servactory/patches/callable_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +RSpec.describe OpenTelemetry::Instrumentation::Servactory::Patches::Callable do + let(:spans) { EXPORTER.finished_spans } + + describe "#call" do + context "when service succeeds" do + subject(:result) { SuccessfulService.call(name: "World") } + + let(:span) { spans.find { |s| s.name == "SuccessfulService call" } } + + before { result } + + it "creates a span" do + expect(span) + .not_to be_nil + end + + it "sets span attributes", :aggregate_failures do + expect(span.attributes["code.namespace"]) + .to eq("SuccessfulService") + expect(span.attributes["code.function"]) + .to eq("call") + expect(span.attributes["servactory.version"]) + .to eq(Servactory::VERSION::STRING) + expect(span.attributes["servactory.result"]) + .to eq("success") + expect(span.attributes["servactory.input_names"]) + .to eq(["name"]) + expect(span.attributes["servactory.output_names"]) + .to eq(["greeting"]) + end + + it "sets OK status" do + expect(span.status.code) + .to eq(OpenTelemetry::Trace::Status::OK) + end + + it "does not alter the return value" do + expect(result.greeting) + .to eq("Hello, World!") + end + end + + context "when service fails" do + subject(:result) { FailingService.call(should_fail: true) } + + let(:span) { spans.find { |s| s.name == "FailingService call" } } + let(:failure_event) { span.events&.find { |e| e.name == "servactory.failure" } } + + before { result } + + it "sets span attributes", :aggregate_failures do + expect(span.attributes["servactory.result"]) + .to eq("failure") + expect(span.status.code) + .to eq(OpenTelemetry::Trace::Status::ERROR) + end + + it "adds a failure event" do + expect(failure_event) + .not_to be_nil + end + + it "returns a failure result" do + expect(result) + .to be_failure + end + end + + context "when service does not fail" do + let(:span) { spans.find { |s| s.name == "FailingService call" } } + + before { FailingService.call(should_fail: false) } + + it "sets success result" do + expect(span.attributes["servactory.result"]) + .to eq("success") + end + end + + context "when service raises unexpected exception" do + let(:span) { spans.find { |s| s.name == "ExceptionService call" } } + let(:exception_event) { span.events&.find { |e| e.name == "exception" } } + + before do + ExceptionService.call + rescue StandardError + nil + end + + it "records the exception on span" do + expect(exception_event) + .not_to be_nil + end + + it "sets error result" do + expect(span.attributes["servactory.result"]) + .to eq("error") + end + + it "re-raises the exception" do + expect { ExceptionService.call } + .to raise_error(StandardError, /unexpected error/) + end + end + end + + describe "#call!" do + context "when service succeeds" do + subject(:result) { SuccessfulService.call!(name: "World") } + + let(:span) { spans.find { |s| s.name == "SuccessfulService call!" } } + + before { result } + + it "creates a span" do + expect(span) + .not_to be_nil + end + + it "sets span attributes", :aggregate_failures do + expect(span.attributes["servactory.result"]) + .to eq("success") + expect(span.status.code) + .to eq(OpenTelemetry::Trace::Status::OK) + end + + it "does not alter the return value" do + expect(result.greeting) + .to eq("Hello, World!") + end + end + + context "when service fails with Servactory failure" do + let(:span) { spans.find { |s| s.name == "FailingService call!" } } + let(:exception_event) { span.events&.find { |e| e.name == "exception" } } + + before do + FailingService.call!(should_fail: true) + rescue StandardError + nil + end + + it "records the exception on span" do + expect(exception_event) + .not_to be_nil + end + + it "sets span attributes", :aggregate_failures do + expect(span.attributes["servactory.result"]) + .to eq("failure") + expect(span.status.code) + .to eq(OpenTelemetry::Trace::Status::ERROR) + end + + it "re-raises the exception" do + expect { FailingService.call!(should_fail: true) } + .to raise_error(StandardError, /Something went wrong/) + end + end + + context "when service raises unexpected exception" do + let(:span) { spans.find { |s| s.name == "ExceptionService call!" } } + let(:exception_event) { span.events&.find { |e| e.name == "exception" } } + + before do + ExceptionService.call! + rescue StandardError + nil + end + + it "records the exception on span" do + expect(exception_event) + .not_to be_nil + end + + it "sets error result" do + expect(span.attributes["servactory.result"]) + .to eq("error") + end + end + end +end diff --git a/spec/opentelemetry/instrumentation/servactory/patches/runner_spec.rb b/spec/opentelemetry/instrumentation/servactory/patches/runner_spec.rb new file mode 100644 index 0000000..878b37c --- /dev/null +++ b/spec/opentelemetry/instrumentation/servactory/patches/runner_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.describe OpenTelemetry::Instrumentation::Servactory::Patches::Runner do + let(:spans) { EXPORTER.finished_spans } + + describe "#call_action" do + context "when service has multiple actions" do + subject(:result) { MultiActionService.call(value: 5) } + + let(:root_span) { spans.find { |s| s.name == "MultiActionService call" } } + let(:step_one_span) { spans.find { |s| s.name == "MultiActionService step_one" } } + let(:step_two_span) { spans.find { |s| s.name == "MultiActionService step_two" } } + + before { result } + + it "creates child spans for each action", :aggregate_failures do + expect(step_one_span) + .not_to be_nil + expect(step_two_span) + .not_to be_nil + end + + it "sets span attributes", :aggregate_failures do + expect(step_one_span.attributes["code.namespace"]) + .to eq("MultiActionService") + expect(step_one_span.attributes["code.function"]) + .to eq("step_one") + expect(step_one_span.attributes["servactory.version"]) + .to eq(Servactory::VERSION::STRING) + end + + it "creates action spans as children of the root span" do + expect(step_one_span.parent_span_id) + .to eq(root_span.span_id) + end + + it "does not alter the return value" do + expect(result.result) + .to eq(11) + end + end + + context "when action raises an exception" do + let(:span) { spans.find { |s| s.name == "ExceptionService blow_up" } } + let(:exception_event) { span.events&.find { |e| e.name == "exception" } } + + before do + ExceptionService.call + rescue StandardError + nil + end + + it "records exception on action span" do + expect(exception_event) + .not_to be_nil + end + + it "sets ERROR status on action span" do + expect(span.status.code) + .to eq(OpenTelemetry::Trace::Status::ERROR) + end + end + end +end diff --git a/spec/opentelemetry/instrumentation/servactory/version_spec.rb b/spec/opentelemetry/instrumentation/servactory/version_spec.rb index 9c0d0af..5409ac8 100644 --- a/spec/opentelemetry/instrumentation/servactory/version_spec.rb +++ b/spec/opentelemetry/instrumentation/servactory/version_spec.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true RSpec.describe OpenTelemetry::Instrumentation::Servactory::VERSION do - it { expect(OpenTelemetry::Instrumentation::Servactory::VERSION::STRING).not_to be_nil } + it { expect(described_class::STRING).not_to be_nil } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a28810c..7c97eab 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,19 @@ # frozen_string_literal: true +require "forwardable" +require "servactory" require "opentelemetry/instrumentation/servactory" +require "opentelemetry-sdk" + +Dir[File.join(__dir__, "support", "**", "*.rb")].each { |file| require file } + +EXPORTER = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + +OpenTelemetry::SDK.configure do |c| + c.error_handler = ->(exception:, message:) { raise(exception || message) } + c.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(EXPORTER)) + c.use("OpenTelemetry::Instrumentation::Servactory") +end RSpec.configure do |config| # Enable flags like --only-failures and --next-failure @@ -17,4 +30,8 @@ # doing truncation. c.max_formatted_output_length = nil end + + config.before do + EXPORTER.reset + end end diff --git a/spec/support/example_services.rb b/spec/support/example_services.rb new file mode 100644 index 0000000..323c905 --- /dev/null +++ b/spec/support/example_services.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class SuccessfulService < Servactory::Base + input :name, type: String + + output :greeting, type: String + + make :build_greeting + + private + + def build_greeting + outputs.greeting = "Hello, #{inputs.name}!" + end +end + +class FailingService < Servactory::Base + input :should_fail, type: [TrueClass, FalseClass] + + make :maybe_fail + + private + + def maybe_fail + fail!(message: "Something went wrong") if inputs.should_fail + end +end + +class MultiActionService < Servactory::Base + input :value, type: Integer + + internal :temp, type: Integer + + output :result, type: Integer + + make :step_one + make :step_two + + private + + def step_one + internals.temp = inputs.value * 2 + end + + def step_two + outputs.result = internals.temp + 1 + end +end + +class ExceptionService < Servactory::Base + make :blow_up + + private + + def blow_up + raise StandardError, "unexpected error" + end +end