From e8d1462f4654d2670cff977b852c7a9bd7022b99 Mon Sep 17 00:00:00 2001 From: Ricardo Paiva Date: Thu, 25 Aug 2022 19:47:59 +0100 Subject: [PATCH] Add otel tesla library (#90) * Add Tesla HTTP client instrumentation library * Update workflows configuration * Update labeler.yml * Update CODEOWNERS * Add LICENSE file Co-authored-by: Bryan Naegele --- .github/labeler.yml | 5 +- .github/workflows/elixir.yml | 37 ++ CODEOWNERS | 1 + instrumentation/opentelemetry_tesla/LICENSE | 201 +++++++++++ instrumentation/opentelemetry_tesla/README.md | 31 ++ .../opentelemetry_tesla_middleware.ex | 93 +++++ instrumentation/opentelemetry_tesla/mix.exs | 62 ++++ instrumentation/opentelemetry_tesla/mix.lock | 24 ++ .../opentelemetry_tesla_middleware_test.exs | 323 ++++++++++++++++++ .../opentelemetry_tesla/test/test_helper.exs | 1 + 10 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 instrumentation/opentelemetry_tesla/LICENSE create mode 100644 instrumentation/opentelemetry_tesla/README.md create mode 100644 instrumentation/opentelemetry_tesla/lib/middleware/opentelemetry_tesla_middleware.ex create mode 100644 instrumentation/opentelemetry_tesla/mix.exs create mode 100644 instrumentation/opentelemetry_tesla/mix.lock create mode 100644 instrumentation/opentelemetry_tesla/test/middleware/opentelemetry_tesla_middleware_test.exs create mode 100644 instrumentation/opentelemetry_tesla/test/test_helper.exs diff --git a/.github/labeler.yml b/.github/labeler.yml index c0b1d1e..f7d7540 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -68,5 +68,8 @@ opentelemetry_redix: opentelemetry_telemetry: - utilities/opentelemetry_telemetry/**/* +opentelemetry_tesla: + - instrumentation/opentelemetry_tesla/**/* + opentelemetry_xray: - - utilities/opentelemetry_xray/**/* + - utilities/opentelemetry_xray/**/* \ No newline at end of file diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index c9040b4..85617fc 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -265,3 +265,40 @@ jobs: run: mix format --check-formatted - name: Test run: mix test + + opentelemetry-tesla: + needs: [test-matrix] + if: (contains(github.event.pull_request.labels.*.name, 'elixir') && contains(github.event.pull_request.labels.*.name, 'opentelemetry_tesla')) + env: + app: 'opentelemetry_tesla' + defaults: + run: + working-directory: instrumentation/${{ env.app }} + runs-on: ubuntu-18.04 + name: Opentelemetry Test test on Elixir ${{ matrix.elixir_version }} (OTP ${{ matrix.otp_version }}) + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.test-matrix.outputs.matrix) }} + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp_version }} + elixir-version: ${{ matrix.elixir_version }} + rebar3-version: ${{ matrix.rebar3_version }} + - name: Cache + uses: actions/cache@v2 + with: + path: | + ~/deps + ~/_build + key: ${{ runner.os }}-build-${{ matrix.otp_version }}-${{ matrix.elixir_version }}-v3-${{ hashFiles('**/mix.lock') }} + - name: Fetch deps + if: steps.deps-cache.outputs.cache-hit != 'true' + run: mix deps.get + - name: Compile project + run: mix compile --warnings-as-errors + - name: Check formatting + run: mix format --check-formatted + - name: Test + run: mix test diff --git a/CODEOWNERS b/CODEOWNERS index 04ca10f..3397119 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -20,3 +20,4 @@ /instrumentation/opentelemetry_phoenix @bryannaegele @tsloughter /instrumentation/opentelemetry_redix @andrewhr /utilities/opentelemetry_telemetry @bryannaegele @tsloughter +/instrumentation/opentelemetry_tesla @ricardoccpaiva diff --git a/instrumentation/opentelemetry_tesla/LICENSE b/instrumentation/opentelemetry_tesla/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/instrumentation/opentelemetry_tesla/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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/instrumentation/opentelemetry_tesla/README.md b/instrumentation/opentelemetry_tesla/README.md new file mode 100644 index 0000000..db283a2 --- /dev/null +++ b/instrumentation/opentelemetry_tesla/README.md @@ -0,0 +1,31 @@ +# OpenTelemetryTesla + +Tesla middleware that creates OpenTelemetry spans and injects tracing headers into HTTP requests for Tesla clients. + + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `opentelemetry_tesla` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:opentelemetry_tesla, "~> 2.0.1"} + ] +end +``` + +## Setup + +Whilst using this middleware is as simple as adding it to your Tesla middlewares configuration, **It's very important to set the correct order of the middlewares** + +The is crucial to correctly get the parameterized version of the URL, something like `/api/users/:id` instead of `/api/users/3`. + +`OpenTelemetry` comes **first**, `PathParams` (if you're using it) comes after. + +```elixir +Tesla.Middleware.OpenTelemetry +Tesla.Middleware.PathParams +``` + diff --git a/instrumentation/opentelemetry_tesla/lib/middleware/opentelemetry_tesla_middleware.ex b/instrumentation/opentelemetry_tesla/lib/middleware/opentelemetry_tesla_middleware.ex new file mode 100644 index 0000000..b7b33b5 --- /dev/null +++ b/instrumentation/opentelemetry_tesla/lib/middleware/opentelemetry_tesla_middleware.ex @@ -0,0 +1,93 @@ +defmodule Tesla.Middleware.OpenTelemetry do + require OpenTelemetry.Tracer + @behaviour Tesla.Middleware + + def call(env, next, _options) do + span_name = get_span_name(env) + + OpenTelemetry.Tracer.with_span span_name, %{kind: :client} do + env + |> Tesla.put_headers(:otel_propagator_text_map.inject([])) + |> Tesla.run(next) + |> set_span_attributes() + |> handle_result() + end + end + + defp get_span_name(env) do + case env.opts[:path_params] do + nil -> "HTTP #{http_method(env.method)}" + _ -> URI.parse(env.url).path + end + end + + defp set_span_attributes({_, %Tesla.Env{} = env} = result) do + OpenTelemetry.Tracer.set_attributes(build_attrs(env)) + + result + end + + defp set_span_attributes(result) do + result + end + + defp handle_result({:ok, %Tesla.Env{status: status} = env}) when status > 400 do + OpenTelemetry.Tracer.set_status(OpenTelemetry.status(:error, "")) + + {:ok, env} + end + + defp handle_result({:error, {Tesla.Middleware.FollowRedirects, :too_many_redirects}} = result) do + OpenTelemetry.Tracer.set_status(OpenTelemetry.status(:error, "")) + + result + end + + defp handle_result({:ok, env}) do + {:ok, env} + end + + defp handle_result(result) do + OpenTelemetry.Tracer.set_status(OpenTelemetry.status(:error, "")) + + result + end + + defp build_attrs(%Tesla.Env{ + method: method, + url: url, + status: status_code, + headers: headers, + query: query + }) do + url = Tesla.build_url(url, query) + uri = URI.parse(url) + + attrs = %{ + "http.method": http_method(method), + "http.url": url, + "http.target": uri.path, + "http.host": uri.host, + "http.scheme": uri.scheme, + "http.status_code": status_code + } + + maybe_append_content_length(attrs, headers) + end + + defp maybe_append_content_length(attrs, headers) do + case Enum.find(headers, fn {k, _v} -> k == "content-length" end) do + nil -> + attrs + + {_key, content_length} -> + Map.put(attrs, :"http.response_content_length", content_length) + end + end + + defp http_method(method) do + method + |> Atom.to_string() + |> String.upcase() + end +end diff --git a/instrumentation/opentelemetry_tesla/mix.exs b/instrumentation/opentelemetry_tesla/mix.exs new file mode 100644 index 0000000..aea1975 --- /dev/null +++ b/instrumentation/opentelemetry_tesla/mix.exs @@ -0,0 +1,62 @@ +defmodule OpentelemetryTesla.MixProject do + use Mix.Project + + def project do + [ + app: :opentelemetry_tesla, + version: "2.0.1", + elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps(), + package: package(), + description: description(), + docs: docs() + ] + end + + defp docs() do + [ + main: "readme", + extras: ["README.md"] + ] + end + + defp description() do + "Tesla middleware that creates OpenTelemetry spans and injects tracing headers into HTTP requests for Tesla clients." + end + + defp package do + [ + name: "opentelemetry_tesla", + licenses: ["Apache-2.0"], + links: %{ + "GitHub" => + "https://github.com/open-telemetry/opentelemetry-erlang-contrib/instrumentation/opentelemetry_tesla", + "OpenTelemetry Erlang" => "https://github.com/open-telemetry/opentelemetry-erlang", + "OpenTelemetry Erlang Contrib" => "https://github.com/open-telemetry/opentelemetry-erlang-contrib", + "OpenTelemetry.io" => "https://opentelemetry.io" + } + ] + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:opentelemetry, "~> 1.0.0", only: :test}, + {:opentelemetry_api, "~> 1.0.0"}, + {:opentelemetry_telemetry, "~> 1.0.0"}, + {:tesla, "~> 1.4"}, + {:ex_doc, "~> 0.24", only: :dev, runtime: false}, + {:bypass, "~> 2.1", only: :test}, + {:jason, "~> 1.3", only: :test} + ] + end +end diff --git a/instrumentation/opentelemetry_tesla/mix.lock b/instrumentation/opentelemetry_tesla/mix.lock new file mode 100644 index 0000000..f0216e2 --- /dev/null +++ b/instrumentation/opentelemetry_tesla/mix.lock @@ -0,0 +1,24 @@ +%{ + "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.19", "de0d033d5ff9fc396a24eadc2fcf2afa3d120841eb3f1004d138cbf9273210e8", [:mix], [], "hexpm", "527ab6630b5c75c3a3960b75844c314ec305c76d9899bb30f71cb85952a9dc45"}, + "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, + "opentelemetry": {:hex, :opentelemetry, "1.0.0", "6e98f4a9230681b2e4c88d45783ce1c02d671ffc0b5ac0cba69a34a3f5ada8d8", [:rebar3], [{:opentelemetry_api, "~> 1.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "08d8697740f70594d05067cb62a0a8845ff568b2d47e1f8c78c46708ab58a74f"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.0.2", "91353ee40583b1d4f07d7b13ed62642abfec6aaa0d8a2114f07edafb2df781c5", [:mix, :rebar3], [], "hexpm", "2a8247f85c44216b883900067478d59955d11e58e5cfca7c884cd4f203ace3ac"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.0.0", "d5982a319e725fcd2305b306b65c18a86afdcf7d96821473cf0649ff88877615", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.0", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "3401d13a1d4b7aa941a77e6b3ec074f0ae77f83b5b2206766ce630123a9291a9"}, + "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, + "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, +} diff --git a/instrumentation/opentelemetry_tesla/test/middleware/opentelemetry_tesla_middleware_test.exs b/instrumentation/opentelemetry_tesla/test/middleware/opentelemetry_tesla_middleware_test.exs new file mode 100644 index 0000000..0f7cfcc --- /dev/null +++ b/instrumentation/opentelemetry_tesla/test/middleware/opentelemetry_tesla_middleware_test.exs @@ -0,0 +1,323 @@ +defmodule Tesla.Middleware.OpenTelemetryTest do + use ExUnit.Case + require Record + + for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do + Record.defrecord(name, spec) + end + + for {name, spec} <- Record.extract_all(from_lib: "opentelemetry_api/include/opentelemetry.hrl") do + Record.defrecord(name, spec) + end + + setup do + bypass = Bypass.open() + + :application.stop(:opentelemetry) + :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) + + :application.set_env(:opentelemetry, :processors, [ + {:otel_batch_processor, %{scheduled_delay_ms: 1, exporter: {:otel_exporter_pid, self()}}} + ]) + + :application.start(:opentelemetry) + + {:ok, bypass: bypass} + end + + test "it records a generic span name if opentelemetry middleware is configured before path params middleware", + %{ + bypass: bypass + } do + defmodule TestClient do + def get(client) do + params = [id: '3'] + + Tesla.get(client, "/users/:id", opts: [path_params: params]) + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + Tesla.Middleware.PathParams + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users/3", fn conn -> + Plug.Conn.resp(conn, 204, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get() + + assert_receive {:span, span(name: "/users/:id", attributes: _attributes)} + end + + test "Records spans for Tesla HTTP client", %{bypass: bypass} do + defmodule TestClient do + def get(client) do + Tesla.get(client, "/users/") + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users", fn conn -> + Plug.Conn.resp(conn, 204, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get() + + assert_receive {:span, span(name: "HTTP GET", attributes: _attributes)} + end + + test "Marks Span status as :error when HTTP request fails", %{bypass: bypass} do + defmodule TestClient do + def get(client) do + Tesla.get(client, "/users/") + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users", fn conn -> + Plug.Conn.resp(conn, 500, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get() + + assert_receive {:span, span(status: {:status, :error, ""})} + end + + test "Marks Span status as :errors when max redirects are exceeded", %{bypass: bypass} do + defmodule TestClient do + def get(client) do + Tesla.get(client, "/users/") + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + {Tesla.Middleware.FollowRedirects, max_redirects: 1} + ] + + Tesla.client(middleware) + end + end + + Bypass.expect(bypass, "GET", "/users", fn conn -> + conn + |> Plug.Conn.put_resp_header("Location", "/users/1") + |> Plug.Conn.resp(301, "") + end) + + Bypass.expect(bypass, "GET", "/users/1", fn conn -> + conn + |> Plug.Conn.put_resp_header("Location", "/users/2") + |> Plug.Conn.resp(301, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get() + + assert_receive {:span, span(status: {:status, :error, ""})} + end + + test "Appends query string parameters to http.url attribute", %{bypass: bypass} do + defmodule TestClient do + def get(client, id) do + params = [id: id] + Tesla.get(client, "/users/:id", opts: [path_params: params]) + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + Tesla.Middleware.PathParams, + {Tesla.Middleware.Query, [token: "some-token", array: ["foo", "bar"]]} + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users/2", fn conn -> + Plug.Conn.resp(conn, 204, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get("2") + + assert_receive {:span, span(name: _name, attributes: attributes)} + + mapped_attributes = :otel_attributes.map(attributes) + + assert mapped_attributes[:"http.url"] == + "http://localhost:#{bypass.port}/users/2?token=some-token&array%5B%5D=foo&array%5B%5D=bar" + end + + test "http.url attribute is correct when request doesn't contain query string parameters", %{ + bypass: bypass + } do + defmodule TestClient do + def get(client, id) do + params = [id: id] + Tesla.get(client, "/users/:id", opts: [path_params: params]) + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + Tesla.Middleware.PathParams, + {Tesla.Middleware.Query, []} + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users/2", fn conn -> + Plug.Conn.resp(conn, 204, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get("2") + + assert_receive {:span, span(name: _name, attributes: attributes)} + + mapped_attributes = :otel_attributes.map(attributes) + + assert mapped_attributes[:"http.url"] == + "http://localhost:#{bypass.port}/users/2" + end + + test "Handles url path arguments correctly", %{bypass: bypass} do + defmodule TestClient do + def get(client, id) do + params = [id: id] + Tesla.get(client, "/users/:id", opts: [path_params: params]) + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + Tesla.Middleware.PathParams, + {Tesla.Middleware.Query, [token: "some-token"]} + ] + + Tesla.client(middleware) + end + end + + Bypass.expect_once(bypass, "GET", "/users/2", fn conn -> + Plug.Conn.resp(conn, 204, "") + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get("2") + + assert_receive {:span, span(name: _name, attributes: attributes)} + assert %{"http.target": "/users/2"} = :otel_attributes.map(attributes) + end + + test "Records http.response_content_length param into the span", %{bypass: bypass} do + defmodule TestClient do + def get(client, id) do + params = [id: id] + Tesla.get(client, "/users/:id", opts: [path_params: params]) + end + + def client(url) do + middleware = [ + {Tesla.Middleware.BaseUrl, url}, + Tesla.Middleware.OpenTelemetry, + Tesla.Middleware.PathParams, + {Tesla.Middleware.Query, [token: "some-token"]} + ] + + Tesla.client(middleware) + end + end + + response = "HELLO 👋" + + Bypass.expect_once(bypass, "GET", "/users/2", fn conn -> + Plug.Conn.resp(conn, 200, response) + end) + + bypass.port + |> endpoint_url() + |> TestClient.client() + |> TestClient.get("2") + + assert_receive {:span, span(name: _name, attributes: attributes)} + + mapped_attributes = :otel_attributes.map(attributes) + + {response_size, _} = Integer.parse(mapped_attributes[:"http.response_content_length"]) + assert response_size == byte_size(response) + end + + test "Injects distributed tracing headers" do + OpentelemetryTelemetry.start_telemetry_span( + "tracer_id", + "my_label", + %{}, + %{kind: :client} + ) + + assert {:ok, + %Tesla.Env{ + headers: [ + {"traceparent", traceparent} + ] + }} = + Tesla.Middleware.OpenTelemetry.call( + %Tesla.Env{url: ""}, + [], + "http://example.com" + ) + + assert is_binary(traceparent) + end + + defp endpoint_url(port), do: "http://localhost:#{port}/" +end diff --git a/instrumentation/opentelemetry_tesla/test/test_helper.exs b/instrumentation/opentelemetry_tesla/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/instrumentation/opentelemetry_tesla/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()