From fb5598800569cd45d97a891b001f4b39efbaf7d7 Mon Sep 17 00:00:00 2001 From: Andrew Rosa Date: Thu, 12 May 2022 13:53:42 -0300 Subject: [PATCH] Telemetry-based Absinthe instrumentation Add instrumentation for `Absinthe`, the GraphQL Elixir-framwork. Relies on some new telemetry work on Absinthe: * [Added telemetry for async helper][1], used to propagate traces * [Improved telemetry for batch helper][2], used to propagate traces * [Tweaked pipeline instrumentation][3], that splits parse/validate/execute phases, following the approach of [JavaScript libraries][4] Span names and attributes also follow the [JavaScript impl][4], the closes to a future [Semantic Attribute][5] spec. The "span tracker" used here is to connect traces in a shape that resembles the query shape instead of execution shape. One example is: ``` query { users { name } } ``` In case `name` is an async resolver (for example), the execution will resembles a recursive function, and such our trace will be: ``` RootQueryType.users User.name User.name User.name ``` With the span tracker, the above trace will become: ``` RootQueryType.users User.name User.name User.name ``` That helps visually scan the traces, and it's friendly to folks who doesn't know much about Absinthe internals. [1]: https://github.com/absinthe-graphql/absinthe/pull/1169 [2]: https://github.com/absinthe-graphql/absinthe/pull/1170 [3]: https://github.com/absinthe-graphql/absinthe/pull/1172 [4]: https://www.npmjs.com/package/@opentelemetry/instrumentation-graphql [5]: https://github.com/open-telemetry/opentelemetry-specification/pull/2456 --- .../opentelemetry_absinthe/.formatter.exs | 5 + .../opentelemetry_absinthe/.gitignore | 23 ++ .../opentelemetry_absinthe/CHANGELOG.md | 5 + .../opentelemetry_absinthe/LICENSE | 201 ++++++++++++ .../opentelemetry_absinthe/README.md | 30 ++ .../opentelemetry_absinthe/config/config.exs | 34 ++ .../opentelemetry_absinthe/config/test.exs | 4 + .../lib/opentelemetry_absinthe.ex | 36 +++ .../opentelemetry_absinthe/async_handler.ex | 66 ++++ .../opentelemetry_absinthe/batch_handler.ex | 138 ++++++++ .../opentelemetry_absinthe/execute_handler.ex | 154 +++++++++ .../opentelemetry_absinthe/parse_handler.ex | 34 ++ .../opentelemetry_absinthe/span_tracker.ex | 102 ++++++ .../validate_handler.ex | 81 +++++ .../opentelemetry_absinthe/mix.exs | 59 ++++ .../opentelemetry_absinthe/mix.lock | 27 ++ .../test/opentelemetry_absinthe_test.exs | 299 ++++++++++++++++++ .../test/test_helper.exs | 1 + 18 files changed, 1299 insertions(+) create mode 100644 instrumentation/opentelemetry_absinthe/.formatter.exs create mode 100644 instrumentation/opentelemetry_absinthe/.gitignore create mode 100644 instrumentation/opentelemetry_absinthe/CHANGELOG.md create mode 100644 instrumentation/opentelemetry_absinthe/LICENSE create mode 100644 instrumentation/opentelemetry_absinthe/README.md create mode 100644 instrumentation/opentelemetry_absinthe/config/config.exs create mode 100644 instrumentation/opentelemetry_absinthe/config/test.exs create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/async_handler.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/batch_handler.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/execute_handler.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/parse_handler.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/span_tracker.ex create mode 100644 instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/validate_handler.ex create mode 100644 instrumentation/opentelemetry_absinthe/mix.exs create mode 100644 instrumentation/opentelemetry_absinthe/mix.lock create mode 100644 instrumentation/opentelemetry_absinthe/test/opentelemetry_absinthe_test.exs create mode 100644 instrumentation/opentelemetry_absinthe/test/test_helper.exs diff --git a/instrumentation/opentelemetry_absinthe/.formatter.exs b/instrumentation/opentelemetry_absinthe/.formatter.exs new file mode 100644 index 00000000..fcc70bb2 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + import_deps: [:absinthe], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/instrumentation/opentelemetry_absinthe/.gitignore b/instrumentation/opentelemetry_absinthe/.gitignore new file mode 100644 index 00000000..946f1f87 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +opentelemetry_redix-*.tar diff --git a/instrumentation/opentelemetry_absinthe/CHANGELOG.md b/instrumentation/opentelemetry_absinthe/CHANGELOG.md new file mode 100644 index 00000000..134621e0 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +* Initial release diff --git a/instrumentation/opentelemetry_absinthe/LICENSE b/instrumentation/opentelemetry_absinthe/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/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_absinthe/README.md b/instrumentation/opentelemetry_absinthe/README.md new file mode 100644 index 00000000..015eb098 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/README.md @@ -0,0 +1,30 @@ +# OpentelemetryAbsinthe + +OpentelemetryAbsinthe uses [telemetry](https://hexdocs.pm/telemetry/) handlers to +create `OpenTelemetry` spans from Absinthe exeuction, resolvers and +middleware. + +## Installation + +The package can be installed by adding `opentelemetry_absinthe` to your list of +dependencies in `mix.exs`: + +```elixir + def deps do + [ + {:opentelemetry_absinthe, "~> 0.1.0"} + ] + end +``` + +## Compatibility Matrix + +| OpentelemetryAbsinthe Version | Otel Version | Notes | +| :------------------------- | :----------- | :---- | +| | | | +| v0.1.0 | v1.0.0 | | + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/opentelemetry_absinthe](https://hexdocs.pm/opentelemetry_absinthe). + diff --git a/instrumentation/opentelemetry_absinthe/config/config.exs b/instrumentation/opentelemetry_absinthe/config/config.exs new file mode 100644 index 00000000..fcf20677 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/config/config.exs @@ -0,0 +1,34 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +import Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# third-party users, it should be done in your "mix.exs" file. + +# You can configure your application as: +# +# config :opentelemetry_absinthe, key: :value +# +# and access this configuration in your application as: +# +# Application.get_env(:opentelemetry_absinthe, :key) +# +# You can also configure a third-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +try do + import_config "#{Mix.env()}.exs" +rescue + _ -> :ok +end diff --git a/instrumentation/opentelemetry_absinthe/config/test.exs b/instrumentation/opentelemetry_absinthe/config/test.exs new file mode 100644 index 00000000..28a095d8 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/config/test.exs @@ -0,0 +1,4 @@ +import Config + +config :opentelemetry, + processors: [{:otel_simple_processor, %{}}] diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe.ex new file mode 100644 index 00000000..d468f238 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe.ex @@ -0,0 +1,36 @@ +defmodule OpentelemetryAbsinthe do + @moduledoc """ + Module for automatic instrumentation of Absinthe resolution. + + It works by listening to [:absinthe, :execute, :operation, :start/:stop] telemetry events, + which are emitted by Absinthe only since v1.5; therefore it won't work on previous versions. + + (you can still call `OpentelemetryAbsinthe.Instrumentation.setup()` in your application startup + code, it just won't do anything.) + """ + alias OpentelemetryAbsinthe.AsyncHandler + alias OpentelemetryAbsinthe.BatchHandler + alias OpentelemetryAbsinthe.ExecuteHandler + alias OpentelemetryAbsinthe.ParseHandler + alias OpentelemetryAbsinthe.ValidateHandler + + @tracer_id __MODULE__ + + @doc """ + Initializes and configures the telemetry handlers. + """ + def setup(_opts \\ []) do + config = %{ + tracer_id: @tracer_id + } + + # Base GraphQL events + ParseHandler.attach(config) + ValidateHandler.attach(config) + ExecuteHandler.attach(config) + + # Absinthe specific events + AsyncHandler.attach(config) + BatchHandler.attach(config) + end +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/async_handler.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/async_handler.ex new file mode 100644 index 00000000..2c29676b --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/async_handler.ex @@ -0,0 +1,66 @@ +defmodule OpentelemetryAbsinthe.AsyncHandler do + @moduledoc false + + alias OpenTelemetry.Span + + # batch events run for each batch group + @async_task_start [:absinthe, :middleware, :async, :task, :start] + @async_task_stop [:absinthe, :middleware, :async, :task, :stop] + @async_task_exception [:absinthe, :middleware, :async, :task, :exception] + + @doc false + def attach(config) do + :telemetry.attach_many( + {__MODULE__, :async_task}, + [ + @async_task_start, + @async_task_stop, + @async_task_exception + ], + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event(event, measurements, metadata, config) + + def handle_event(@async_task_start, _measurements, metadata, config) do + attributes = [] + + parent_ctx = OpentelemetryProcessPropagator.fetch_parent_ctx(1, :"$callers") + + if parent_ctx != :undefined do + OpenTelemetry.Ctx.attach(parent_ctx) + end + + OpentelemetryTelemetry.start_telemetry_span( + config.tracer_id, + :"absinthe.middleware.async", + metadata, + %{attributes: attributes} + ) + end + + def handle_event(@async_task_stop, _measurements, metadata, config) do + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + def handle_event(@async_task_exception, _measurements, metadata, config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + case metadata do + %{kind: :error, reason: reason, stacktrace: stacktrace} -> + Span.record_exception(ctx, reason, stacktrace) + Span.set_status(ctx, OpenTelemetry.status(:error, format_error(reason))) + + _otherwise -> + :ok + end + + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + defp format_error(error) when is_exception(error), do: Exception.message(error) + defp format_error(error), do: inspect(error) +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/batch_handler.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/batch_handler.ex new file mode 100644 index 00000000..beb89129 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/batch_handler.ex @@ -0,0 +1,138 @@ +defmodule OpentelemetryAbsinthe.BatchHandler do + @moduledoc false + + alias OpenTelemetry.Span + alias OpentelemetryAbsinthe.SpanTracker + + # batch events run for each batch group + @batch_start [:absinthe, :middleware, :batch, :start] + @batch_stop [:absinthe, :middleware, :batch, :stop] + @batch_exception [:absinthe, :middleware, :batch, :exception] + + # batch post events run once for each resolver, to extract data from batch + @batch_post_start [:absinthe, :middleware, :batch, :post, :start] + @batch_post_stop [:absinthe, :middleware, :batch, :post, :stop] + @batch_post_exception [:absinthe, :middleware, :batch, :post, :exception] + + @doc false + def attach(config) do + :telemetry.attach_many( + {__MODULE__, :batch}, + [ + @batch_start, + @batch_stop, + @batch_exception + ], + &__MODULE__.handle_event/4, + config + ) + + :telemetry.attach_many( + {__MODULE__, :batch_post}, + [ + @batch_post_start, + @batch_post_stop, + @batch_post_exception + ], + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event(event, measurements, metadata, config) + + def handle_event(@batch_start, _measurements, metadata, config) do + {module, function} = + case metadata.batch_fun do + {m, f} -> {m, f} + {m, f, _} -> {m, f} + end + + attributes = [ + "absinthe.middleware.batch.module": inspect(module), + "absinthe.middleware.batch.function": "#{function}/2" + ] + + parent_ctx = SpanTracker.root_parent_ctx() + + if parent_ctx != :undefined do + OpenTelemetry.Ctx.attach(parent_ctx) + end + + OpentelemetryTelemetry.start_telemetry_span( + config.tracer_id, + :"absinthe.middleware.batch", + metadata, + %{ + attributes: attributes + } + ) + end + + def handle_event(@batch_stop, _measurements, metadata, config) do + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + def handle_event(@batch_exception, _measurements, metadata, config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + case metadata do + %{kind: :error, reason: reason, stacktrace: stacktrace} -> + Span.record_exception(ctx, reason, stacktrace) + Span.set_status(ctx, OpenTelemetry.status(:error, format_error(reason))) + + _otherwise -> + :ok + end + + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + def handle_event(@batch_post_start, _measurements, metadata, config) do + resolution = metadata.resolution + path = Absinthe.Resolution.path(resolution) + + attributes = [ + "absinthe.middleware.batch.batch_key": inspect(metadata.batch_key), + "absinthe.middleware.batch.post_batch_fun": inspect(metadata.post_batch_fun) + ] + + parent_ctx = SpanTracker.find_parent_ctx(path) + + if parent_ctx != :undefined do + OpenTelemetry.Ctx.attach(parent_ctx) + end + + OpentelemetryTelemetry.start_telemetry_span( + config.tracer_id, + :"absinthe.middleware.batch.post", + metadata, + %{ + attributes: attributes + } + ) + end + + def handle_event(@batch_post_stop, _measurements, metadata, config) do + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + def handle_event(@batch_post_exception, _measurements, metadata, config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + case metadata do + %{kind: :error, reason: reason, stacktrace: stacktrace} -> + Span.record_exception(ctx, reason, stacktrace) + Span.set_status(ctx, OpenTelemetry.status(:error, format_error(reason))) + + _otherwise -> + :ok + end + + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + defp format_error(error) when is_exception(error), do: Exception.message(error) + defp format_error(error), do: inspect(error) +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/execute_handler.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/execute_handler.ex new file mode 100644 index 00000000..fa119c88 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/execute_handler.ex @@ -0,0 +1,154 @@ +defmodule OpentelemetryAbsinthe.ExecuteHandler do + @moduledoc false + + alias OpentelemetryAbsinthe.SpanTracker + + @operation_start [:absinthe, :execute, :operation, :start] + @operation_stop [:absinthe, :execute, :operation, :stop] + + @resolve_field_start [:absinthe, :resolve, :field, :start] + @resolve_field_stop [:absinthe, :resolve, :field, :stop] + + @doc false + def attach(config) do + :telemetry.attach_many( + {__MODULE__, :operation}, + [ + @operation_start, + @operation_stop + ], + &__MODULE__.handle_event/4, + config + ) + + :telemetry.attach_many( + {__MODULE__, :resolve_field}, + [ + @resolve_field_start, + @resolve_field_stop + ], + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event(event, measurements, metadata, config) + + def handle_event(@operation_start, _measurements, metadata, config) do + attributes = [ + "graphql.source": metadata.options[:document] + ] + + OpentelemetryTelemetry.start_telemetry_span(config.tracer_id, :"graphql.execute", metadata, %{ + attributes: attributes + }) + + SpanTracker.init() + end + + def handle_event(@operation_stop, _measurements, metadata, config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + operation = Absinthe.Blueprint.current_operation(metadata.blueprint) + + attributes = + [ + "absinthe.schema": inspect(metadata.blueprint.schema) + ] + |> add_operation_attributes(operation) + + OpenTelemetry.Span.set_attributes(ctx, attributes) + + SpanTracker.terminate() + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + def handle_event(@resolve_field_start, _measurements, metadata, config) do + resolution = metadata.resolution + path = Absinthe.Resolution.path(resolution) + type = Absinthe.Type.name(resolution.definition.schema_node.type, resolution.schema) + parent_type = Absinthe.Type.name(resolution.parent_type) + + span_name = "#{parent_type}.#{resolution.definition.name}" + + attributes = + [ + "absinthe.schema": inspect(resolution.schema), + "graphql.field.name": resolution.definition.name, + "graphql.field.type": type, + "graphql.field.path": Enum.join(path, ".") + ] + |> add_resolver_attributes(resolver_name(resolution)) + + parent_ctx = SpanTracker.find_parent_ctx(path) + + if parent_ctx != :undefined do + OpenTelemetry.Ctx.attach(parent_ctx) + end + + OpentelemetryTelemetry.start_telemetry_span(config.tracer_id, span_name, metadata, %{ + attributes: attributes + }) + + SpanTracker.push_ctx(path) + end + + def handle_event(@resolve_field_stop, _measurements, metadata, config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + if Enum.any?(metadata.resolution.errors) do + message = "#{Enum.map_join(metadata.resolution.errors, "\n", &inspect/1)}" + OpenTelemetry.Span.set_status(ctx, OpenTelemetry.status(:error, message)) + end + + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + defp add_operation_attributes(attrs, nil), do: attrs + + defp add_operation_attributes(attrs, %{name: nil} = operation) do + [ + "graphql.operation.type": operation.type + ] ++ attrs + end + + defp add_operation_attributes(attrs, operation) do + [ + "graphql.operation.name": operation.name, + "graphql.operation.type": operation.type + ] ++ attrs + end + + defp add_resolver_attributes(attrs, nil), do: attrs + + defp add_resolver_attributes(attrs, resolver_name) do + ["absinthe.resolver": resolver_name] ++ attrs + end + + defp resolver_name(resolution) do + case resolver_mfa(resolution) do + {module, function, arity} -> + "#{inspect(module)}.#{function}/#{arity}" + + nil -> + nil + end + end + + defp resolver_mfa(%{middleware: middleware}) do + middleware + |> Enum.find(&match?({{Absinthe.Resolution, :call}, _resolver}, &1)) + |> case do + {_, {mod, fun}} -> + {mod, fun, 3} + + {_, fun} when is_function(fun) -> + info = Function.info(fun) + {info[:module], info[:name], info[:arity]} + + nil -> + nil + end + end +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/parse_handler.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/parse_handler.ex new file mode 100644 index 00000000..e23eca15 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/parse_handler.ex @@ -0,0 +1,34 @@ +defmodule OpentelemetryAbsinthe.ParseHandler do + @moduledoc false + + @parse_start [:absinthe, :parse, :start] + @parse_stop [:absinthe, :parse, :stop] + + @doc false + def attach(config) do + :telemetry.attach_many( + {__MODULE__, :parse}, + [ + @parse_start, + @parse_stop + ], + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event(event, measurements, metadata, config) + + def handle_event(@parse_start, _measurements, metadata, config) do + attributes = [] + + OpentelemetryTelemetry.start_telemetry_span(config.tracer_id, :"graphql.parse", metadata, %{ + attributes: attributes + }) + end + + def handle_event(@parse_stop, _measurements, metadata, config) do + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/span_tracker.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/span_tracker.ex new file mode 100644 index 00000000..d4c62593 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/span_tracker.ex @@ -0,0 +1,102 @@ +defmodule OpentelemetryAbsinthe.SpanTracker do + @moduledoc false + + @doc """ + Initialize Span tracking for current process. Must to be called at operation + start. + + Expects span to be used as "root", presumably the `graphql.execute` span. + """ + def init do + root_ctx = OpenTelemetry.Ctx.get_current() + update_stack(fn _ -> [{[], root_ctx}] end) + end + + @doc """ + Cleanup Span tracking data from current process dictionary. Must be called + at operation stop. + """ + def terminate do + delete_stack() + end + + @doc """ + Store context into current process tracker stack, stored inside + the process dictionary. + + Stores paths in reverse order to ease parent lookup. + """ + def push_ctx(path) do + ctx = OpenTelemetry.Ctx.get_current() + update_stack(&[{:lists.reverse(path), ctx} | &1]) + end + + @doc """ + Find a suitable parent span given path, or `nil` otherwise. + """ + def find_parent_ctx(path) do + get_stack() |> find_parent_by_path(:lists.reverse(path)) + end + + @doc """ + Find the "root" span, i.e., `graphql.execute`. Useful to attach middleware + like batch spans, as they run on separate phases from resolution. + """ + def root_parent_ctx do + case List.last(get_parent_stack()) do + {_path, ctx} -> ctx + _ -> :undefined + end + end + + defp find_parent_by_path(stack, path) do + stack + |> Enum.find_value(fn {parent_path, ctx} -> + if match_parent?(parent_path, path), do: ctx + end) || :undefined + end + + # Recursively search if candidate is a subpath of parent. Expects paths to be in + # reverse order, i.e., [:grandchild, :child, :parent]. + # + # Short-circuit on paths impossible to match. + defp match_parent?(parent, candidate) + defp match_parent?(path, path), do: true + defp match_parent?(parent, path) when length(parent) >= length(path), do: false + defp match_parent?(parent, [_ | rest]), do: match_parent?(parent, rest) + defp match_parent?(_parent, _path), do: false + + @ctx_stack {__MODULE__, :ctx_stack} + + defp get_parent_stack do + case Process.get(@ctx_stack) do + nil -> + pids = Process.get(:"$callers", []) + Enum.find_value(pids, &fetch_ctx_stack(&1)) + + ctx_stack -> + ctx_stack + end || [] + end + + defp get_stack do + Process.get(@ctx_stack) || [] + end + + defp update_stack(fun) do + Process.put(@ctx_stack, fun.(get_stack())) + end + + defp delete_stack do + Process.delete(@ctx_stack) + end + + defp fetch_ctx_stack(pid) do + with {_, pdict} <- Process.info(pid, :dictionary), + {_key, ctx_stack} <- List.keyfind(pdict, @ctx_stack, 0) do + ctx_stack + else + _not_found -> nil + end + end +end diff --git a/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/validate_handler.ex b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/validate_handler.ex new file mode 100644 index 00000000..8f8a32f1 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/lib/opentelemetry_absinthe/validate_handler.ex @@ -0,0 +1,81 @@ +defmodule OpentelemetryAbsinthe.ValidateHandler do + @moduledoc false + + @validate_start [:absinthe, :validate, :start] + @validate_stop [:absinthe, :validate, :stop] + + @doc false + def attach(config) do + :telemetry.attach_many( + {__MODULE__, :validate}, + [ + @validate_start, + @validate_stop + ], + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event(event, measurements, metadata, config) + + def handle_event(@validate_start, _measurements, metadata, config) do + attributes = [] + + OpentelemetryTelemetry.start_telemetry_span( + config.tracer_id, + :"graphql.validate", + metadata, + %{ + attributes: attributes + } + ) + end + + def handle_event(@validate_stop, _measurements, metadata, config) do + OpentelemetryTelemetry.set_current_telemetry_span(config.tracer_id, metadata) + + case metadata.blueprint.execution.validation_errors do + [] -> + :ok + + errors -> + message = + errors + |> Enum.uniq() + |> Enum.map(&format_error/1) + |> Jason.encode!(pretty: true) + + OpenTelemetry.Tracer.add_event(:"graphql.validation.error", message: message) + end + + OpentelemetryTelemetry.end_telemetry_span(config.tracer_id, metadata) + end + + defp format_error(%Absinthe.Phase.Error{locations: []} = error) do + error_object = %{message: error.message} + Map.merge(error.extra, error_object) + end + + defp format_error(%Absinthe.Phase.Error{} = error) do + error_object = %{ + message: error.message, + locations: Enum.flat_map(error.locations, &format_location/1) + } + + error_object = + case error.path do + [] -> error_object + path -> Map.put(error_object, :path, path) + end + + Map.merge(Map.new(error.extra), error_object) + end + + defp format_location(%{line: line, column: col}) do + [%{line: line || 0, column: col || 0}] + end + + defp format_location(_), do: [] +end diff --git a/instrumentation/opentelemetry_absinthe/mix.exs b/instrumentation/opentelemetry_absinthe/mix.exs new file mode 100644 index 00000000..8ff00c24 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/mix.exs @@ -0,0 +1,59 @@ +defmodule OpentelemetryAbsinthe.MixProject do + use Mix.Project + + def project do + [ + app: :opentelemetry_absinthe, + description: description(), + version: "0.1.1", + elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps(), + elixirc_paths: elixirc_paths(Mix.env()), + package: package(), + source_url: + "https://github.com/open-telemetry/opentelemetry-erlang-contrib/tree/main/instrumentation/opentelemetry_absinthe" + ] + end + + defp description do + "Trace Absinthe queries with OpenTelemetry." + end + + defp package do + [ + files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*), + licenses: ["Apache-2.0"], + links: %{ + "GitHub" => + "https://github.com/open-telemetry/opentelemetry-erlang-contrib/tree/main/instrumentation/opentelemetry_absinthe", + "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 + + def application do + [] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:absinthe, ">= 1.7.0"}, + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.28.0", only: [:dev], runtime: false}, + {:jason, "~> 1.0"}, + {:opentelemetry, "~> 1.0", only: [:dev, :test]}, + {:opentelemetry_api, "~> 1.0"}, + {:opentelemetry_exporter, "~> 1.0", only: [:dev, :test]}, + {:opentelemetry_process_propagator, "~> 0.1.0"}, + {:opentelemetry_telemetry, "~> 1.0"}, + {:telemetry, "~> 0.4 or ~> 1.0"} + ] + end +end diff --git a/instrumentation/opentelemetry_absinthe/mix.lock b/instrumentation/opentelemetry_absinthe/mix.lock new file mode 100644 index 00000000..502ad553 --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/mix.lock @@ -0,0 +1,27 @@ +%{ + "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, + "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, + "chatterbox": {:hex, :ts_chatterbox, "0.11.0", "b8f372c706023eb0de5bf2976764edb27c70fe67052c88c1f6a66b3a5626847f", [:rebar3], [{:hpack, "~>0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "722fe2bad52913ab7e87d849fc6370375f0c961ffb2f0b5e6d647c9170c382a6"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, + "gproc": {:hex, :gproc, "0.8.0", "cea02c578589c61e5341fce149ea36ccef236cc2ecac8691fba408e7ea77ec2f", [:rebar3], [], "hexpm", "580adafa56463b75263ef5a5df4c86af321f68694e7786cb057fd805d1e2a7de"}, + "grpcbox": {:hex, :grpcbox, "0.14.0", "3eb321bcd2275baf8b54cf381feb7b0559a50c02544de28fda039c7f2f9d1a7a", [:rebar3], [{:acceptor_pool, "~>1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~>0.11.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~>0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~>0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "e24159b7b6d3f9869bbe528845c0125fed2259366ba908fd04a1f45fe81d0660"}, + "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"}, + "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.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "opentelemetry": {:hex, :opentelemetry, "1.0.3", "0d04f8f2c8b45c75cd7a6b31c0e3699f00bf82feee610f97f10971ddbcbb2010", [:rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "1e94db9989276f24c3ce9f0df2f46074a42f3f6c19057a2c1a6f863b6a1f1463"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.0.3", "77f9644c42340cd8b18c728cde4822ed55ae136f0d07761b78e8c54da46af93a", [:mix, :rebar3], [], "hexpm", "4293e06bd369bc004e6fad5edbb56456d891f14bd3f9f1772b18f1923e0678ea"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.0.4", "60a64c75633a82b6c36a20043be355ac72a7b9b21633edd47407924c5596dde0", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.11", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "61da65290fbb6cac3459b84b8cd630795bf608df93a2b2cc49251cae78200e5e"}, + "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.1.1", "81ec6825971903486ee73be23230d06764df39ee11011e520f601aa2bb21c893", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "0572f26066bbb0457e22e169f966c0140a8f95237716c9c6ba4458d6dbaa724b"}, + "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"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "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"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.14.0", "6d1638d56ac68b25c987d401dffb7cd059281339aadc3f8bf27ab33ee19ddbfe", [:rebar3], [{:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "b4452ddd3ae89cd84451afa0e218cb3ccd5178fe3c1de7fabcbddb12a137bcf4"}, +} diff --git a/instrumentation/opentelemetry_absinthe/test/opentelemetry_absinthe_test.exs b/instrumentation/opentelemetry_absinthe/test/opentelemetry_absinthe_test.exs new file mode 100644 index 00000000..6599179b --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/test/opentelemetry_absinthe_test.exs @@ -0,0 +1,299 @@ +defmodule OpentelemetryAbsintheTest do + use ExUnit.Case, async: false + + doctest OpentelemetryAbsinthe + + require OpenTelemetry.Tracer + require OpenTelemetry.Span + 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 + + defmodule Schema do + use Absinthe.Schema + + require OpenTelemetry.Tracer + + @organizations 1..3 + |> Map.new( + &{&1, + %{ + id: &1, + name: "Organization: ##{&1}" + }} + ) + @users 1..3 + |> Enum.map( + &%{ + id: &1, + name: "User: ##{&1}", + organization_id: &1 + } + ) + + object :organization do + field :id, :integer + field :name, :string + end + + object :user do + field :name, :string + + field :organization, :organization do + resolve fn user, _, _ -> + batch({__MODULE__, :by_id}, user.organization_id, fn batch -> + OpenTelemetry.Tracer.with_span "post user.organization batch" do + {:ok, Map.get(batch, user.organization_id)} + end + end) + end + end + end + + query do + field :users, list_of(:user) do + resolve fn _, _, _ -> + async(fn -> + OpenTelemetry.Tracer.with_span "inside users async" do + {:ok, @users} + end + end) + end + end + + field :organization, :organization do + arg :id, non_null(:integer) + + resolve fn _, %{id: id}, _ -> + batch({__MODULE__, :by_id}, id, fn batch -> + OpenTelemetry.Tracer.with_span "post organization batch" do + {:ok, Map.get(batch, id)} + end + end) + end + end + + def by_id(_, ids) do + OpenTelemetry.Tracer.with_span "inside by_id" do + Map.take(@organizations, ids) + end + end + end + end + + setup do + :otel_simple_processor.set_exporter(:otel_exporter_pid, self()) + + OpenTelemetry.Tracer.start_span("test") + + on_exit(fn -> + OpenTelemetry.Tracer.end_span() + end) + end + + describe "graphql.execute" do + test "query with named operation" do + OpentelemetryAbsinthe.setup() + + assert {:ok, _result} = + """ + query ResolverTest { + users { + name + } + } + """ + |> Absinthe.run(Schema) + + assert_receive {:span, + span( + name: :"graphql.execute", + span_id: execute_span_id, + attributes: attributes + )} + + assert %{ + "graphql.operation.name": "ResolverTest", + "graphql.operation.type": :query, + "absinthe.schema": "OpentelemetryAbsintheTest.Schema" + } = :otel_attributes.map(attributes) + + assert_receive {:span, + span( + name: "RootQueryType.users", + parent_span_id: ^execute_span_id, + attributes: attributes + )} + + assert %{ + "graphql.field.name": "users", + "graphql.field.path": "users", + "graphql.field.type": "[User]", + "absinthe.schema": "OpentelemetryAbsintheTest.Schema" + } = :otel_attributes.map(attributes) + + refute_receive {:span, span(name: "User.name")} + end + + test "resolver spans replicate query structure and skip default resolvers" do + OpentelemetryAbsinthe.setup() + + assert {:ok, _result} = + """ + query ResolverTest { + users { + name + organization { + name + } + } + } + """ + |> Absinthe.run(Schema) + + assert_receive {:span, span(name: :"graphql.execute", span_id: execute_span_id)} + + assert_receive {:span, + span( + name: "RootQueryType.users", + parent_span_id: ^execute_span_id, + span_id: user_span_id, + attributes: attributes + )} + + assert %{ + "graphql.field.name": "users", + "graphql.field.path": "users", + "graphql.field.type": "[User]", + "absinthe.schema": "OpentelemetryAbsintheTest.Schema" + } = :otel_attributes.map(attributes) + + refute_receive {:span, span(name: "User.name")} + + assert_receive {:span, + span( + name: "User.organization", + parent_span_id: ^user_span_id, + attributes: attributes + )} + + assert %{ + "graphql.field.name": "organization", + "graphql.field.path": "users.0.organization", + "graphql.field.type": "Organization", + "absinthe.schema": "OpentelemetryAbsintheTest.Schema" + } = :otel_attributes.map(attributes) + + refute_receive {:span, span(name: "Organization.name")} + end + end + + describe "absinthe.middleware.async" do + test "create spans for helpers and propagate context to async functions" do + OpentelemetryAbsinthe.setup() + + assert {:ok, _result} = + """ + query AsyncTest { + users { + name + } + } + """ + |> Absinthe.run(Schema) + + assert_receive {:span, + span( + name: :"graphql.execute", + span_id: execute_span_id + )} + + assert_receive {:span, + span( + name: "RootQueryType.users", + span_id: resolve_span_id, + parent_span_id: ^execute_span_id + )} + + assert_receive {:span, + span( + name: :"absinthe.middleware.async", + span_id: async_span_id, + parent_span_id: ^resolve_span_id + )} + + assert_receive {:span, + span( + name: "inside users async", + parent_span_id: ^async_span_id + )} + end + end + + describe "absinthe.middleware.batch" do + test "create spans for helpers and propagate context to batch functions" do + OpentelemetryAbsinthe.setup() + + assert {:ok, _result} = + """ + query BatchTest { + organization(id: 1) { + name + } + } + """ + |> Absinthe.run(Schema) + + assert_receive {:span, + span( + name: :"graphql.execute", + span_id: execute_span_id + )} + + assert_receive {:span, + span( + name: "RootQueryType.organization", + span_id: resolve_span_id, + parent_span_id: ^execute_span_id + )} + + assert_receive {:span, + span( + name: :"absinthe.middleware.batch", + span_id: batch_span_id, + parent_span_id: ^execute_span_id, + attributes: attributes + )} + + assert %{ + "absinthe.middleware.batch.function": "by_id/2", + "absinthe.middleware.batch.module": "OpentelemetryAbsintheTest.Schema" + } = :otel_attributes.map(attributes) + + assert_receive {:span, + span( + name: :"absinthe.middleware.batch.post", + span_id: batch_post_span_id, + parent_span_id: ^resolve_span_id + )} + + assert_receive {:span, + span( + name: "inside by_id", + parent_span_id: ^batch_span_id + )} + + assert_receive {:span, + span( + name: "post organization batch", + parent_span_id: ^batch_post_span_id + )} + end + end +end diff --git a/instrumentation/opentelemetry_absinthe/test/test_helper.exs b/instrumentation/opentelemetry_absinthe/test/test_helper.exs new file mode 100644 index 00000000..6a0af57d --- /dev/null +++ b/instrumentation/opentelemetry_absinthe/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start(capture_log: true)