Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions instrumentation/action_view/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# OpenTelemetry ActionView Instrumentation

The ActionView instrumentation is a community-maintained instrumentation for the ActionView portion of the [Ruby on Rails][rails-home] web-application framework.
The OpenTelemetry ActionView gem is a community maintained instrumentation for the ActionView portion of the [Ruby on Rails][rails-home] web-application framework.

## How do I get started?

Install the gem using:

```console
gem install opentelemetry-instrumentation-action_view
gem install opentelemetry-instrumentation-rails
```

Or, if you use [bundler][bundler-home], include `opentelemetry-instrumentation-action_view` in your `Gemfile`.
Expand All @@ -19,7 +18,6 @@

```ruby
OpenTelemetry::SDK.configure do |c|
c.use 'OpenTelemetry::Instrumentation::Rails'
c.use 'OpenTelemetry::Instrumentation::ActionView'
end
```
Expand All @@ -32,6 +30,41 @@
end
```

## Active Support Instrumentation

This instrumentation relies on `ActiveSupport::Notifications` and registers subscriptions to listen to relevant events and report them as spans.

See the table below for details of what [Rails Framework Hook Events](https://guides.rubyonrails.org/active_support_instrumentation.html#action-view) are recorded by this instrumentation:

| Event Name | Creates Span? | Notes |
| - | - | - |
| `render_template.action_view` | :white_check_mark: | Captures template rendering operations |
| `render_partial.action_view` | :white_check_mark: | Captures partial template rendering operations |
| `render_collection.action_view` | :white_check_mark: | Captures collection rendering operations |
| `render_layout.action_view` | :white_check_mark: | Captures layout rendering operations |

## Semantic Conventions

This instrumentation follows OpenTelemetry semantic conventions for view rendering. The Rails ActiveSupport notification payload keys are automatically transformed to semantic convention attribute names:

| Rails Notification Key | Semantic Convention Attribute | Description |
| ---------------------- | ----------------------------- | ------------------------------------------------- |
| `identifier` | `code.filepath` | The template file path being rendered |
| `layout` | `view.layout.code.filepath` | The layout template file path (if applicable) |
| `count` | `view.collection.count` | The number of items in a collection render |

### Attributes

Attributes that are specific to this instrumentation are recorded for each event:

| Attribute Name | Type | Notes |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `code.filepath` | String | Template or partial file path (e.g., `"posts/index"`, `"posts/_form"`) |

Check failure on line 62 in instrumentation/action_view/README.md

View workflow job for this annotation

GitHub Actions / Markdown Lint Check

Table column style

instrumentation/action_view/README.md:62:177 MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"] https://github.com/DavidAnson/markdownlint/blob/v0.39.0/doc/md060.md
| `view.layout.code.filepath` | String | Layout file path (e.g., `"application"`) - only present for `render_template.action_view` events when a layout is used |

Check failure on line 63 in instrumentation/action_view/README.md

View workflow job for this annotation

GitHub Actions / Markdown Lint Check

Table column style

instrumentation/action_view/README.md:63:177 MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"] https://github.com/DavidAnson/markdownlint/blob/v0.39.0/doc/md060.md
| `view.collection.count` | Integer | Number of items rendered - only present for `render_collection.action_view` events |

Check failure on line 64 in instrumentation/action_view/README.md

View workflow job for this annotation

GitHub Actions / Markdown Lint Check

Table column style

instrumentation/action_view/README.md:64:177 MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"] https://github.com/DavidAnson/markdownlint/blob/v0.39.0/doc/md060.md

**Note:** The `locals` hash parameter is not recorded as an attribute because OpenTelemetry specification v1.10.0 only supports primitive types (string, boolean, numeric, and arrays of primitives) as span attributes, and the locals hash contains complex Ruby objects.

## Examples

Example usage can be seen in the [`./example/trace_request_demonstration.ru` file](https://github.com/open-telemetry/opentelemetry-ruby-contrib/blob/main/instrumentation/action_view/example/trace_request_demonstration.ru)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ module ActionView
render_layout.action_view
].freeze

# Maps Rails ActiveSupport notification payload keys to OpenTelemetry semantic convention attribute names
PAYLOAD_KEY_MAPPING = {
'identifier' => 'code.filepath',
'layout' => 'view.layout.code.filepath',
'count' => 'view.collection.count'
}.freeze

# Transforms ActionView notification payload keys to semantic convention names
PAYLOAD_TRANSFORMER = lambda do |payload|
payload.each_with_object({}) do |(key, value), transformed|
semantic_key = PAYLOAD_KEY_MAPPING[key.to_s]
transformed[semantic_key] = value if semantic_key
end
end

# This Railtie sets up subscriptions to relevant ActionView notifications
class Railtie < ::Rails::Railtie
config.after_initialize do
Expand All @@ -22,11 +37,14 @@ class Railtie < ::Rails::Railtie
instance = ::OpenTelemetry::Instrumentation::ActionView::Instrumentation.instance
span_name_formatter = instance.config[:legacy_span_names] ? ::OpenTelemetry::Instrumentation::ActiveSupport::LEGACY_NAME_FORMATTER : nil

# Use custom payload transformer if not overridden by user config
payload_transform = instance.config[:notification_payload_transform] || PAYLOAD_TRANSFORMER

SUBSCRIPTIONS.each do |subscription_name|
::OpenTelemetry::Instrumentation::ActiveSupport.subscribe(
instance.tracer,
subscription_name,
instance.config[:notification_payload_transform],
payload_transform,
instance.config[:disallowed_notification_payload_keys],
span_name_formatter: span_name_formatter
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

# Copyright The OpenTelemetry Authors
#
# SPDX-License-Identifier: Apache-2.0

require 'test_helper'
require 'action_controller/test_case'

class ActionViewEventsTest < ActionController::TestCase
tests PostsController

def exporter
EXPORTER
end

def spans
exporter.finished_spans
end

def setup
super
@routes = TestApp.routes
exporter.reset
end

def test_render_template
get :index

template_spans = spans.select { |s| s.name == 'render_template.action_view' }

span = template_spans.first
assert_equal :internal, span.kind
assert_includes span.attributes['code.filepath'], 'posts/index'
assert_includes span.attributes['view.layout.code.filepath'], 'application'
end

def test_render_template_without_layout
get :api

template_spans = spans.select { |s| s.name == 'render_template.action_view' }

span = template_spans.first
assert_includes span.attributes['code.filepath'], 'posts/api'
end

def test_render_partial
get :with_partial

partial_spans = spans.select { |s| s.name == 'render_partial.action_view' }
assert_not_empty partial_spans

span = partial_spans.first
assert_equal :internal, span.kind
assert_includes span.attributes['code.filepath'], '_form'
end

def test_render_collection
get :with_collection

collection_spans = spans.select { |s| s.name == 'render_collection.action_view' }

span = collection_spans.first
assert_equal :internal, span.kind
assert_includes span.attributes['code.filepath'], '_item'
assert_equal 3, span.attributes['view.collection.count']
end

def test_render_template_with_local_params
get :with_locals

template_spans = spans.select { |s| s.name == 'render_template.action_view' }

span = template_spans.first
assert_equal :internal, span.kind
assert_includes span.attributes['identifier'], 'posts/with_locals'
end

def test_render_layout
get :index

layout_spans = spans.select { |s| s.name == 'render_layout.action_view' }

span = layout_spans.first
assert_equal :internal, span.kind
assert_includes span.attributes['code.filepath'], 'application'
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

require 'test_helper'

describe OpenTelemetry::Instrumentation::ActionView do
describe OpenTelemetry::Instrumentation::ActionView::Instrumentation do
let(:instrumentation) { OpenTelemetry::Instrumentation::ActionView::Instrumentation.instance }

it 'has #name' do
Expand All @@ -24,4 +24,18 @@
instrumentation.instance_variable_set(:@installed, false)
end
end

describe '#compatible' do
it 'when action_view is available' do
_(instrumentation.compatible?).must_equal true
end
end

describe 'configuration' do
it 'has default values' do
_(instrumentation.config[:disallowed_notification_payload_keys]).must_equal []
_(instrumentation.config[:notification_payload_transform]).must_be_nil
_(instrumentation.config[:legacy_span_names]).must_equal false
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

# Copyright The OpenTelemetry Authors
#
# SPDX-License-Identifier: Apache-2.0

require 'test_helper'

describe OpenTelemetry::Instrumentation::ActionView::Railtie do
let(:instrumentation) { OpenTelemetry::Instrumentation::ActionView::Instrumentation.instance }

it 'subscribes to all ActionView notification events' do
subscriptions = OpenTelemetry::Instrumentation::ActionView::SUBSCRIPTIONS

_(subscriptions).must_include 'render_template.action_view'
_(subscriptions).must_include 'render_partial.action_view'
_(subscriptions).must_include 'render_collection.action_view'
_(subscriptions).must_include 'render_layout.action_view'
end

it 'creates subscriptions for each event' do
OpenTelemetry::Instrumentation::ActionView::SUBSCRIPTIONS.each do |subscription_name|
listeners = ActiveSupport::Notifications.notifier.listeners_for(subscription_name)

_(listeners).wont_be_empty
_(listeners.any? { |l| l.instance_variable_get(:@delegate).is_a?(OpenTelemetry::Instrumentation::ActiveSupport::SpanSubscriber) }).must_equal true
end
end

it 'uses ActiveSupport instrumentation' do
# Verify that ActiveSupport instrumentation is installed
active_support_instrumentation = OpenTelemetry::Instrumentation::ActiveSupport::Instrumentation.instance
_(active_support_instrumentation.installed?).must_equal true
end

describe 'subscription configuration' do
it 'passes notification_payload_transform to subscriptions' do
# The config should be accessible through the instrumentation instance
_(instrumentation.config[:notification_payload_transform]).must_be_nil
end

it 'passes disallowed_notification_payload_keys to subscriptions' do
_(instrumentation.config[:disallowed_notification_payload_keys]).must_equal []
end

it 'passes legacy_span_names to subscriptions' do
_(instrumentation.config[:legacy_span_names]).must_equal false
end
end

describe 'payload transformation' do
it 'transforms mapped keys and omits unmapped keys' do
transformed = OpenTelemetry::Instrumentation::ActionView::PAYLOAD_TRANSFORMER.call(
{ identifier: '/app/views/posts/index.html.erb', layout: 'application', count: 5, custom_key: 'value' }
)

_(transformed['code.filepath']).must_equal '/app/views/posts/index.html.erb'
_(transformed['view.layout.code.filepath']).must_equal 'application'
_(transformed['view.collection.count']).must_equal 5
_(transformed).wont_include 'identifier'
_(transformed).wont_include 'layout'
_(transformed).wont_include 'count'
_(transformed).wont_include 'custom_key'
end
end
end
64 changes: 64 additions & 0 deletions instrumentation/action_view/test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
require 'bundler/setup'
Bundler.require(:default, :development, :test)

require 'active_support'
require 'active_support/railtie'
require 'action_controller'
require 'action_controller/railtie'
require 'action_view'
require 'action_view/railtie'
require 'rails'
require 'opentelemetry-instrumentation-action_view'
require 'minitest/autorun'
require 'webmock/minitest'
Expand All @@ -24,3 +30,61 @@
c.use 'OpenTelemetry::Instrumentation::ActionView'
c.add_span_processor span_processor
end

# Minimal Rails application for testing
class TestApp < Rails::Application
config.eager_load = false
config.logger = Logger.new(File::Constants::NULL)
config.hosts << 'www.example.com'

# Required for Rails initialization
credentials.secret_key_base = 'test_secret_key_base'
end

TestApp.initialize!

# Set up views directory
ActionController::Base.prepend_view_path(File.expand_path('views', __dir__))

# Define test controllers
class PostsController < ActionController::Base
layout 'application'

def index
@posts = ['Post 1', 'Post 2', 'Post 3']
render template: 'posts/index'
end

def show
@post = 'Single Post'
render template: 'posts/show'
end

def api
render template: 'posts/api', layout: false, formats: [:json]
end

def with_partial
render template: 'posts/with_partial'
end

def with_collection
@items = ['Item 1', 'Item 2', 'Item 3']
render template: 'posts/with_collection'
end

def with_locals
local_items = ['Item 1', 'Item 2', 'Item 3']
render template: 'posts/with_locals', locals: { items: local_items }
end
end

# Set up routes
TestApp.routes.draw do
get '/posts', to: 'posts#index'
get '/posts/show', to: 'posts#show'
get '/posts/api', to: 'posts#api'
get '/posts/with_partial', to: 'posts#with_partial'
get '/posts/with_collection', to: 'posts#with_collection'
get '/posts/with_locals', to: 'posts#with_locals'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Test App</title>
</head>
<body>
<%= yield %>
</body>
</html>
3 changes: 3 additions & 0 deletions instrumentation/action_view/test/views/posts/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="form">
<p>This is a form partial</p>
</div>
3 changes: 3 additions & 0 deletions instrumentation/action_view/test/views/posts/_item.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="item">
<%= item %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "status": "ok" }
6 changes: 6 additions & 0 deletions instrumentation/action_view/test/views/posts/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h1>Posts Index</h1>
<ul>
<% @posts.each do |post| %>
<li><%= post %></li>
<% end %>
</ul>
2 changes: 2 additions & 0 deletions instrumentation/action_view/test/views/posts/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>Post Show</h1>
<p><%= @post %></p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>With Collection</h1>
<%= render partial: 'item', collection: @items %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>With Locals</h1>
<%= render partial: 'item', collection: @items %>
Loading
Loading