appoptics_apm 4.12.2 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build_and_release_gem.yml +103 -0
  3. data/.github/workflows/build_for_packagecloud.yml +70 -0
  4. data/.github/workflows/docker-images.yml +47 -0
  5. data/.github/workflows/run_cpluplus_tests.yml +73 -0
  6. data/.github/workflows/run_tests.yml +168 -0
  7. data/.github/workflows/scripts/test_install.rb +23 -0
  8. data/.github/workflows/swig/swig-v4.0.2.tar.gz +0 -0
  9. data/.github/workflows/test_on_4_linux.yml +159 -0
  10. data/.gitignore +17 -25
  11. data/.travis.yml +17 -14
  12. data/Gemfile +1 -25
  13. data/README.md +4 -6
  14. data/appoptics_apm.gemspec +11 -5
  15. data/examples/prepend.rb +13 -0
  16. data/examples/sdk_examples.rb +16 -0
  17. data/ext/oboe_metal/extconf.rb +25 -31
  18. data/ext/oboe_metal/lib/liboboe-1.0-alpine-x86_64.so.0.0.0.sha256 +1 -0
  19. data/ext/oboe_metal/lib/liboboe-1.0-x86_64.so.0.0.0.sha256 +1 -0
  20. data/ext/oboe_metal/src/README.md +6 -0
  21. data/ext/oboe_metal/src/VERSION +2 -1
  22. data/ext/oboe_metal/src/frames.cc +246 -0
  23. data/ext/oboe_metal/src/frames.h +40 -0
  24. data/ext/oboe_metal/src/init_appoptics_apm.cc +5 -4
  25. data/ext/oboe_metal/src/logging.cc +95 -0
  26. data/ext/oboe_metal/src/logging.h +35 -0
  27. data/ext/oboe_metal/src/oboe.h +8 -5
  28. data/ext/oboe_metal/src/oboe_api.cpp +40 -14
  29. data/ext/oboe_metal/src/oboe_api.hpp +29 -8
  30. data/ext/oboe_metal/src/oboe_debug.h +1 -0
  31. data/ext/oboe_metal/src/oboe_swig_wrap.cc +85 -21
  32. data/ext/oboe_metal/src/profiling.cc +435 -0
  33. data/ext/oboe_metal/src/profiling.h +78 -0
  34. data/ext/oboe_metal/test/CMakeLists.txt +53 -0
  35. data/ext/oboe_metal/test/FindGMock.cmake +43 -0
  36. data/ext/oboe_metal/test/README.md +56 -0
  37. data/ext/oboe_metal/test/frames_test.cc +164 -0
  38. data/ext/oboe_metal/test/profiling_test.cc +93 -0
  39. data/ext/oboe_metal/test/ruby_inc_dir.rb +8 -0
  40. data/ext/oboe_metal/test/ruby_prefix.rb +8 -0
  41. data/ext/oboe_metal/test/ruby_test_helper.rb +67 -0
  42. data/ext/oboe_metal/test/test.h +11 -0
  43. data/ext/oboe_metal/test/test_main.cc +32 -0
  44. data/lib/appoptics_apm/api/metrics.rb +3 -0
  45. data/lib/appoptics_apm/base.rb +1 -1
  46. data/lib/appoptics_apm/config.rb +11 -2
  47. data/lib/appoptics_apm/frameworks/rails/inst/connection_adapters/utils5x.rb +7 -1
  48. data/lib/appoptics_apm/inst/rack.rb +13 -6
  49. data/lib/appoptics_apm/inst/redis.rb +1 -2
  50. data/lib/appoptics_apm/noop/context.rb +3 -0
  51. data/lib/appoptics_apm/noop/metadata.rb +4 -1
  52. data/lib/appoptics_apm/noop/profiling.rb +21 -0
  53. data/lib/appoptics_apm/oboe_init_options.rb +26 -22
  54. data/lib/appoptics_apm/support/profiling.rb +18 -0
  55. data/lib/appoptics_apm/support/transaction_metrics.rb +1 -1
  56. data/lib/appoptics_apm/support/transaction_settings.rb +2 -2
  57. data/lib/appoptics_apm/support/x_trace_options.rb +2 -2
  58. data/lib/appoptics_apm/support_report.rb +2 -2
  59. data/lib/appoptics_apm/test.rb +4 -3
  60. data/lib/appoptics_apm/util.rb +1 -1
  61. data/lib/appoptics_apm/version.rb +3 -3
  62. data/lib/appoptics_apm/xtrace.rb +1 -1
  63. data/lib/appoptics_apm.rb +3 -1
  64. data/lib/oboe_metal.rb +2 -2
  65. data/lib/rails/generators/appoptics_apm/templates/appoptics_apm_initializer.rb +24 -0
  66. data/log/.keep +0 -0
  67. metadata +46 -16
  68. data/.travis/bundle.sh +0 -9
@@ -0,0 +1,53 @@
1
+ cmake_minimum_required(VERSION 3.13)
2
+ project(test)
3
+
4
+ # specify the C++ standard
5
+ set(CMAKE_CXX_STANDARD 11)
6
+ set(CMAKE_CXX_STANDARD_REQUIRED True)
7
+ # set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR} ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_LIST_DIR}/FindGMock.cmake)
8
+
9
+ include(FetchContent)
10
+ FetchContent_Declare(
11
+ googletest
12
+ URL https://github.com/google/googletest/archive/609281088cfefc76f9d0ce82e1ff6c30cc3591e5.zip
13
+ # URL https://github.com/google/googletest/archive/refs/tags/release-1.11.0.zip
14
+ )
15
+
16
+ # For Windows: Prevent overriding the parent project's compiler/linker settings
17
+ set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
18
+ FetchContent_MakeAvailable(googletest)
19
+
20
+ include_directories(
21
+ ${gtest_SOURCE_DIR}/include
22
+ ../src/
23
+ $ENV{RUBY_INC_DIR}
24
+ $ENV{RUBY_INC_DIR}/x86_64-linux/
25
+ )
26
+
27
+ link_directories(
28
+ # /usr/lib/
29
+ $ENV{RUBY_PREFIX}/lib/
30
+ ../../../lib/
31
+ ../lib
32
+ )
33
+
34
+ enable_testing()
35
+ set (sources
36
+ test_main.cc
37
+ frames_test.cc
38
+ profiling_test.cc
39
+ )
40
+
41
+ ## Link runTests with what we want to test and the GTest and pthread library
42
+ add_executable(runTests ${sources})
43
+ target_link_libraries(runTests
44
+ # ${GTEST_LIBRARIES}
45
+ gtest
46
+ appoptics_apm.so
47
+ liboboe.so
48
+ libruby.so
49
+ pthread
50
+ )
51
+
52
+ include(GoogleTest)
53
+ gtest_discover_tests(runTests)
@@ -0,0 +1,43 @@
1
+ # This content copied from
2
+ # https://git.simply-life.net/simply-life.net/talltower/-/blob/639293a366da43eb94a72d2e7596242314c9809c/cmake/FindGMock.cmake
3
+
4
+
5
+ # Try to find GMock
6
+ find_package(GTest)
7
+
8
+ # the following issues a warning, but it works nonetheless
9
+ find_package(PkgConfig)
10
+ pkg_check_modules(PC_GMOCK QUIET gmock)
11
+ set(GMOCK_DEFINITIONS ${PC_GMOCK_CFLAGS_OTHER})
12
+
13
+ find_path(GMOCK_INCLUDE_DIR gmock.h
14
+ HINTS ${PC_GMOCK_INCLUDEDIR} ${PC_GMOCK_INCLUDE_DIRS}
15
+ PATH_SUFFIXES gmock)
16
+
17
+ find_library(GMOCK_LIBRARY NAMES gmock libgmock
18
+ HINTS ${PC_GMOCK_LIBDIR} ${PC_GMOCK_LIBRARY_DIRS} )
19
+
20
+ find_library(GMOCK_MAIN_LIBRARY NAMES gmock_main libgmock_main
21
+ HINTS ${PC_GMOCK_LIBDIR} ${PC_GMOCK_LIBRARY_DIRS} )
22
+
23
+ include(FindPackageHandleStandardArgs)
24
+ # handle the QUIETLY and REQUIRED arguments and set GMOCK_FOUND to TRUE
25
+ # if all listed variables are TRUE
26
+ find_package_handle_standard_args(GMock DEFAULT_MSG
27
+ GMOCK_LIBRARY GMOCK_INCLUDE_DIR GTEST_FOUND)
28
+
29
+ mark_as_advanced(GMOCK_INCLUDE_DIR GMOCK_LIBRARY GMOCK_MAIN_LIBRARY)
30
+
31
+ set(GMOCK_LIBRARIES ${GMOCK_LIBRARY} )
32
+ set(GMOCK_INCLUDE_DIRS ${GMOCK_INCLUDE_DIR} )
33
+ set(GMOCK_MAIN_LIBRARIES ${GMOCK_MAIN_LIBRARY} )
34
+
35
+ if (NOT TARGET GMock)
36
+ add_library(GMock IMPORTED SHARED)
37
+ set_property(TARGET GMock PROPERTY IMPORTED_LOCATION ${GMOCK_LIBRARY})
38
+ set_property(TARGET GMock PROPERTY INTERFACE_INCLUDE_DIRECTORY ${GMOCK_INCLUDE_DIR})
39
+
40
+ add_library(GMockMain IMPORTED SHARED)
41
+ set_property(TARGET GMockMain PROPERTY IMPORTED_LOCATION ${GMOCK_MAIN_LIBRARY})
42
+ set_property(TARGET GMockMain PROPERTY INTERFACE_LINK_LIBRARIES GMock GTest)
43
+ endif()
@@ -0,0 +1,56 @@
1
+ C-code tests:
2
+
3
+ CMakeLists.txt includes downloading and compiling googletest if necessary
4
+
5
+ In the ext/oboe_metal/test directory:
6
+
7
+ Set an environment variable for the current path:
8
+ ```
9
+ export TEST_DIR=`pwd`
10
+ ```
11
+
12
+ Every time the ruby version changes the appoptics_apm gem needs to be
13
+ re-installed or its c++-code recompiled and relinked
14
+
15
+ These environment variables need to be set every time the ruby version is set:
16
+ ```
17
+ export RUBY_INC_DIR=$(ruby ruby_inc_dir.rb)
18
+ export RUBY_PREFIX=$(ruby ruby_prefix.rb)
19
+ ```
20
+
21
+ create the Makefile (needs to be remade when the ruby version changes)
22
+ ```
23
+ cmake -S . -B build
24
+ ```
25
+ build
26
+ ```
27
+ cmake --build build
28
+ ```
29
+ run
30
+ ```
31
+ cd build && ctest && cd -
32
+ ```
33
+
34
+ Most testing of profiling is done via Ruby integration tests
35
+
36
+ For example logging is tested in Ruby tests that verify the different
37
+ KVs and values in the resulting traces, using the same approach as
38
+ for traces without profiling.
39
+
40
+ Gotchas:
41
+
42
+ - In alpine the `ruby/config.h` file is in an architecture specific folder and needs
43
+ to be symlinked to the location set via RUBY_INC_DIR (see: Dockerfile_alpine)
44
+
45
+ TODO:
46
+
47
+ - write a script for this
48
+
49
+ ```
50
+ export TEST_DIR=`pwd`
51
+ export RUBY_INC_DIR=$(ruby ruby_inc_dir.rb)
52
+ export RUBY_PREFIX=$(ruby ruby_prefix.rb)
53
+ cmake -S . -B build
54
+ cmake --build build
55
+ cd build && ctest && cd -
56
+ ```
@@ -0,0 +1,164 @@
1
+
2
+
3
+ #include <string.h>
4
+
5
+ #include <algorithm>
6
+
7
+ #include "../src/profiling.h"
8
+ #include "../src/frames.h"
9
+ #include "gtest/gtest.h"
10
+ #include "ruby/debug.h"
11
+ #include "ruby/ruby.h"
12
+ #include "test.h"
13
+
14
+ extern unordered_map<VALUE, FrameData> cached_frames;
15
+
16
+ static VALUE test_frames[BUF_SIZE];
17
+ static int test_lines[BUF_SIZE];
18
+ int test_num;
19
+
20
+ static int ruby_version;
21
+
22
+ VALUE RubyCallsFrames::c_get_frames() {
23
+ test_num = rb_profile_frames(1, sizeof(test_frames) / sizeof(VALUE), test_frames, test_lines);
24
+ return Qnil;
25
+ }
26
+
27
+ void Init_RubyCallsFrames() {
28
+ static VALUE cTest = rb_define_module("RubyCalls");
29
+ rb_define_singleton_method(cTest, "get_frames", reinterpret_cast<VALUE (*)(...)>(RubyCallsFrames::c_get_frames), 0);
30
+
31
+ VALUE result;
32
+ result = rb_eval_string("RUBY_VERSION[0].to_i");
33
+ ruby_version = NUM2INT(result);
34
+ };
35
+
36
+ TEST(Frames, reserve_cached_frames) {
37
+ // it should only reserve once used during init
38
+ // unordered_map grows automatically
39
+ cached_frames.clear();
40
+
41
+ Frames::reserve_cached_frames();
42
+ int bucket_count = cached_frames.bucket_count();
43
+
44
+ Frames::reserve_cached_frames();
45
+ EXPECT_EQ(bucket_count, cached_frames.bucket_count());
46
+ }
47
+
48
+ TEST(Frames, collect_frame_data) {
49
+ rb_eval_string("TestMe::Snapshot::all_kinds");
50
+
51
+ int num = Frames::remove_garbage(test_frames, test_num);
52
+
53
+ vector<FrameData> data;
54
+ // Ruby 3 reports a <cfunc>, before the "take_snapshot" method
55
+ // we have to adjust the index of the trace we are checking
56
+ int i = ruby_version == 2 ? 0 : 1;
57
+ Frames::collect_frame_data(test_frames, i + 1, data);
58
+
59
+ EXPECT_EQ("take_snapshot", data[i].method) << "method name incorrect";
60
+ EXPECT_EQ("TestMe::Snapshot", data[i].klass) << "klass name incorrect";
61
+ std::size_t found = data[i].file.find("ext/oboe_metal/test/ruby_test_helper.rb");
62
+ EXPECT_EQ(data[i].file.length() - 39, found)
63
+ << "filename incorrect " << found << " " << data[i].file.length();
64
+ EXPECT_EQ(7, data[i].lineno) << "line number incorrect";
65
+ }
66
+
67
+ TEST(Frames, remove_garbage) {
68
+ // run some Ruby code and get a snapshot
69
+ rb_eval_string("TestMe::Snapshot::all_kinds");
70
+
71
+ int num = Frames::remove_garbage(test_frames, test_num);
72
+
73
+ int expected = (ruby_version == 2) ? 7 : 9;
74
+ EXPECT_EQ(expected, num)
75
+ << "wrong number of expected frames after remove_garbage";
76
+ // check no lineno 0 frame at top
77
+ VALUE val;
78
+ int i = (ruby_version == 2) ? 0 : 1;
79
+ val = rb_profile_frame_first_lineno(test_frames[i]); // returns line number
80
+ if (RB_TYPE_P(val, T_FIXNUM)) {
81
+ EXPECT_NE(0, NUM2INT(val))
82
+ << "the frame with linenumber 0 was not removed";
83
+ } else {
84
+ EXPECT_TRUE(false) << " ************ line number not an int **********";
85
+ }
86
+ // check no repeated frames
87
+ for (i = 0; i < num; i++)
88
+ for (int j = i + 1; j < num; j++)
89
+ EXPECT_NE(test_frames[i], test_frames[j])
90
+ << "not all repeated frames were removed";
91
+ }
92
+
93
+ TEST(Frames, num_matching) {
94
+ VALUE a[BUF_SIZE];
95
+ VALUE b[BUF_SIZE];
96
+
97
+ int a_num = 0;
98
+ int b_num = 0;
99
+ EXPECT_EQ(0, Frames::num_matching(a, a_num, b, b_num))
100
+ << "* empty frames array should have 0 matches";
101
+
102
+ a[0] = (VALUE)11;
103
+ a[1] = (VALUE)12;
104
+ a[2] = (VALUE)13;
105
+ b[0] = (VALUE)11;
106
+ b[1] = (VALUE)12;
107
+ b[2] = (VALUE)13;
108
+ a_num = 3;
109
+ b_num = 3;
110
+ EXPECT_EQ(3, Frames::num_matching(a, a_num, b, b_num))
111
+ << "* equal frames array should have matched";
112
+
113
+ b[1] = (VALUE)222;
114
+ EXPECT_EQ(1, Frames::num_matching(a, a_num, b, b_num))
115
+ << "* only one should match for same length but different content";
116
+
117
+ b[1] = (VALUE)12;
118
+ a[3] = 14;
119
+ a_num = 4;
120
+ EXPECT_EQ(0, Frames::num_matching(a, a_num, b, b_num))
121
+ << "* different length, frames NOT matching from the end";
122
+
123
+ a[0] = 10;
124
+ a[1] = 11;
125
+ a[2] = 12;
126
+ a[3] = 13;
127
+ EXPECT_EQ(3, Frames::num_matching(a, a_num, b, b_num))
128
+ << "* different length, frames matching from the end";
129
+
130
+ b[0] = (VALUE)18;
131
+ b[1] = (VALUE)19;
132
+ b[2] = (VALUE)11;
133
+ b[3] = (VALUE)12;
134
+ b[4] = (VALUE)13;
135
+ b_num = 5;
136
+
137
+ EXPECT_EQ(3, Frames::num_matching(a, a_num, b, b_num))
138
+ << "* different length, frames matching from the end";
139
+ }
140
+
141
+ TEST(Frames, cached_frames) {
142
+ cached_frames.clear();
143
+ // run some Ruby code and get a snapshot
144
+ rb_eval_string("TestMe::Snapshot::all_kinds");
145
+
146
+ Frames::remove_garbage(test_frames, test_num);
147
+
148
+ // Check the expected size
149
+ int expected = (ruby_version == 2) ? 8 : 10;
150
+ EXPECT_EQ(expected, cached_frames.size());
151
+
152
+ // check that each frame is cached
153
+ for (int i = 0; i < test_num; i++)
154
+ EXPECT_EQ(1, cached_frames.count(test_frames[i]));
155
+
156
+ // repeat
157
+ rb_eval_string("TestMe::Snapshot::all_kinds");
158
+ Frames::remove_garbage(test_frames, test_num);
159
+
160
+ expected = (ruby_version == 2) ? 9 : 11;
161
+ EXPECT_EQ(expected, cached_frames.size()); // +1 for an extra main frame
162
+ for (int i = 0; i < test_num; i++)
163
+ EXPECT_EQ(1, cached_frames.count(test_frames[i]));
164
+ }
@@ -0,0 +1,93 @@
1
+
2
+ #include "../src/frames.h"
3
+
4
+ #include <string.h>
5
+
6
+ #include <algorithm>
7
+ #include <thread>
8
+ #include <array>
9
+
10
+ #include "../src/profiling.h"
11
+ #include "gtest/gtest.h"
12
+ #include "ruby/debug.h"
13
+ #include "ruby/ruby.h"
14
+ #include "test.h"
15
+
16
+ extern atomic_bool profiling_shut_down;
17
+ // extern oboe_reporter_t *cur_reporter;
18
+
19
+ // FIXME how can I access profiling_shut_down ?
20
+ TEST(Profiling, try_catch_shutdown) {
21
+ EXPECT_FALSE(profiling_shut_down);
22
+
23
+ int result;
24
+ result = Profiling::try_catch_shutdown([] {
25
+ // provoke exception
26
+ std::string ().replace (100, 1, 1, 'c');
27
+ return 0;
28
+ }, "Profiling::try_catch()");
29
+
30
+ EXPECT_NE(0, result);
31
+ EXPECT_TRUE(profiling_shut_down);
32
+
33
+ // reset global var
34
+ profiling_shut_down = false;
35
+ }
36
+
37
+ TEST(Profiling, oboe_0_profiling) {
38
+ atomic_bool atomic_a1{true};
39
+ atomic_bool atomic_a2{false};
40
+
41
+ atomic_bool running;
42
+
43
+ cout << running << endl;
44
+ cout << running.exchange(true) << endl;
45
+ cout << running.exchange(true) << endl;
46
+
47
+ // cout << "prev val " << atomic_a2.exchange(false) << endl;
48
+ // cout << atomic_a2 << endl;
49
+ // cout << "prev val " << atomic_a2.exchange(true) << endl;
50
+ // cout << atomic_a2 << endl;
51
+ // cout << "prev val " << atomic_a2.exchange(true) << endl;
52
+ // cout << atomic_a2 << endl;
53
+ // cout << "prev val " << atomic_a2.exchange(false) << endl;
54
+ // cout << atomic_a2 << endl;
55
+
56
+
57
+
58
+ // static bool b1{true};
59
+ // static bool b2{false};
60
+
61
+ // array<thread, 4> threads;
62
+
63
+ // for (auto& t : threads) {
64
+ // t = thread([] { Profiling::profiler_signal_handler(0, NULL, NULL); });
65
+ // }
66
+
67
+ // cout << "waiting..." << endl;
68
+
69
+ // for (auto& t : threads) {
70
+ // t.join();
71
+ // }
72
+
73
+ // cout << "Done." << endl;
74
+
75
+
76
+ // cout << atomic_a1 << ", " << b1 << endl;
77
+ // cout << atomic_a1 << ", " << b1 << ", " << atomic_a1.compare_exchange_weak(b1, false) << endl;
78
+ // cout << atomic_a1 << ", " << b1 << endl;
79
+ // cout << atomic_a2 << ", " << b2 << endl;
80
+ // cout << atomic_a2 << ", " << b2 << ", " << atomic_a2.compare_exchange_weak(b2, true) << endl;
81
+ // cout << atomic_a2 << ", " << b2 << endl;
82
+ // cout << endl;
83
+
84
+ // cout << atomic_a1 << ", " << b1 << endl;
85
+ // cout << atomic_a1 << ", " << b1 << ", " << atomic_a1.compare_exchange_weak(b1, false) << endl;
86
+ // cout << atomic_a1 << ", " << b1 << endl;
87
+ // cout << atomic_a2 << ", " << b2 << endl;
88
+ // cout << atomic_a2 << ", " << b2 << ", " << atomic_a2.compare_exchange_weak(b2, true) << endl;
89
+ // cout << atomic_a2 << ", " << b2 << endl;
90
+ // cout << endl;
91
+
92
+
93
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2021 SolarWinds, LLC.
4
+ # All rights reserved.
5
+
6
+ require 'mkmf'
7
+ puts $topdir
8
+
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2021 SolarWinds, LLC.
4
+ # All rights reserved.
5
+
6
+ require 'mkmf'
7
+ puts CONFIG["prefix"]
8
+
@@ -0,0 +1,67 @@
1
+ class TestMe
2
+ class Snapshot
3
+
4
+ class << self
5
+ # !!! do not shift the definition of take_snapshot from line 7 !!!
6
+ # the line number is used to verify a test in frames_test.cc
7
+ def take_snapshot
8
+ # puts "getting frames ...."
9
+ begin
10
+ ::RubyCalls::get_frames
11
+ rescue => e
12
+ puts "oops, getting frames didn't work"
13
+ puts e
14
+ end
15
+ end
16
+
17
+ def all_kinds
18
+ begin
19
+ Teddy.new.sing do
20
+ take_snapshot
21
+ end
22
+ rescue => e
23
+ puts "Ruby call did not work"
24
+ puts e
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ class Teddy
31
+
32
+ attr_accessor :name
33
+
34
+ def sing
35
+ 3.times do
36
+ yodel do
37
+ html_wrap("title", "Hello") { |_html| yield }
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def yodel
45
+ a_proc = -> (x) { x * x; yield }
46
+ in_block(&a_proc)
47
+ end
48
+
49
+ def in_block(&block)
50
+ begin
51
+ yield 7
52
+ # puts "block called!"
53
+ rescue => e
54
+ puts "no, this should never happen"
55
+ puts e
56
+ end
57
+ end
58
+
59
+ def html_wrap(tag, text)
60
+ html = "<#{tag}>#{text}</#{tag}>"
61
+ yield html
62
+ end
63
+
64
+ end
65
+ end
66
+
67
+