diff --git a/instrumentation/opentelemetry_grpc/.formatter.exs b/instrumentation/opentelemetry_grpc/.formatter.exs new file mode 100644 index 00000000..d2cda26e --- /dev/null +++ b/instrumentation/opentelemetry_grpc/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/instrumentation/opentelemetry_grpc/.gitignore b/instrumentation/opentelemetry_grpc/.gitignore new file mode 100644 index 00000000..528e34cd --- /dev/null +++ b/instrumentation/opentelemetry_grpc/.gitignore @@ -0,0 +1,4 @@ +cover +deps +_build + diff --git a/instrumentation/opentelemetry_grpc/CHANGELOG.md b/instrumentation/opentelemetry_grpc/CHANGELOG.md new file mode 100644 index 00000000..1d013ff9 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/instrumentation/opentelemetry_grpc/LICENSE b/instrumentation/opentelemetry_grpc/LICENSE new file mode 100644 index 00000000..4947287f --- /dev/null +++ b/instrumentation/opentelemetry_grpc/LICENSE @@ -0,0 +1,177 @@ + + 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 \ No newline at end of file diff --git a/instrumentation/opentelemetry_grpc/README.md b/instrumentation/opentelemetry_grpc/README.md new file mode 100644 index 00000000..e26190a7 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/README.md @@ -0,0 +1,93 @@ +# OpentelemetryGrpc + +[![EEF Observability WG project](https://img.shields.io/badge/EEF-Observability-black)](https://github.com/erlef/eef-observability-wg) +[![Hex.pm](https://img.shields.io/hexpm/v/opentelemetry_grpc)](https://hex.pm/packages/opentelemetry_grpc) + +OpenTelemetry instrumentation for gRPC clients and servers in Elixir, enabling distributed tracing, error tracking, and context propagation across services. + +This library provides comprehensive tracing for gRPC applications, supporting both client and server instrumentation with proper context propagation according to OpenTelemetry semantic conventions. + +## Features + +- **Client Instrumentation**: Automatic tracing of outgoing gRPC requests +- **Server Instrumentation**: Automatic tracing of incoming gRPC requests +- **Context Propagation**: Proper trace context propagation between services +- **Error Handling**: Comprehensive error tracking and status reporting using gRPC status codes +- **Semantic Conventions**: Follows OpenTelemetry semantic conventions for RPC + +## Installation + +Add `opentelemetry_grpc` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:opentelemetry_grpc, "~> 1.0"} + ] +end +``` + +## Usage + +### Basic Setup + +For most applications, you can set up both client and server instrumentation: + +```elixir +# In your application startup (e.g., Application.start/2) +OpentelemetryGrpc.setup() +``` + +### Client Instrumentation + +To instrument only gRPC client calls: + +```elixir +OpentelemetryGrpc.Client.setup() +``` + +For context propagation in client requests, add the interceptor to your gRPC channel. The OpenTelemetry interceptor should typically be the first in the list to ensure proper context propagation: + +```elixir +{:ok, channel} = GRPC.Stub.connect("localhost:50051", + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor]) + +# Use the channel for your gRPC calls +MyService.Stub.my_method(channel, request) +``` + +### Server Instrumentation + +To instrument only gRPC server requests: + +```elixir +OpentelemetryGrpc.Server.setup() +``` + +By default, server instrumentation includes context propagation from incoming request headers. To control span relationships: + +```elixir +# Create child spans (default) - spans should be part of the same trace +OpentelemetryGrpc.Server.setup(span_relationship: :child) + +# Create span links instead of parent-child relationships - use for fire-and-forget or detached async processing +OpentelemetryGrpc.Server.setup(span_relationship: :link) + +# Disable context propagation entirely - useful for isolated traces +OpentelemetryGrpc.Server.setup(span_relationship: :none) +``` + +### Advanced Configuration + +You can pass server-specific options through the main setup function: + +```elixir +OpentelemetryGrpc.setup(server: [span_relationship: :link]) +``` + +## Context Propagation + +The library supports OpenTelemetry context propagation: + +- **Client**: Use `OpentelemetryGrpc.ContextPropagatorInterceptor` to inject trace context into outgoing requests +- **Server**: Context is automatically extracted from incoming request headers (when enabled) diff --git a/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc.ex b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc.ex new file mode 100644 index 00000000..64d4bbb7 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc.ex @@ -0,0 +1,101 @@ +defmodule OpentelemetryGrpc do + alias OpentelemetryGrpc.Client + alias OpentelemetryGrpc.Server + + @options_schema NimbleOptions.new!( + server: [ + type: + {:or, + [ + {:keyword_list, Server.options_schema()}, + {:in, [:disabled]} + ]}, + default: [], + doc: """ + Server instrumentation options. Set to `:disabled` to skip server setup. \n\n #{NimbleOptions.docs(Server.options_schema(), nest_level: 1)} + """ + ], + client: [ + type: {:in, [[], :disabled]}, + default: [], + doc: """ + Client instrumentation. Set to `:disabled` to skip client setup. + """ + ] + ) + + @moduledoc """ + OpenTelemetry instrumentation for gRPC clients and servers. + + This library provides OpenTelemetry tracing for gRPC applications, supporting + both client and server instrumentation with proper context propagation. + + ## Client Instrumentation + + To instrument gRPC client calls, set up the client telemetry handler: + + OpentelemetryGrpc.Client.setup() + + For context propagation in client requests, add the interceptor to your gRPC channel: + + {:ok, channel} = GRPC.Stub.connect("localhost:50051", + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + ## Server Instrumentation + + To instrument gRPC server requests, set up the server telemetry handler: + + OpentelemetryGrpc.Server.setup() + + By default, server instrumentation includes context propagation. To control span relationships: + + OpentelemetryGrpc.Server.setup(span_relationship: :link) + + ## Complete Setup + + To set up both client and server instrumentation: + + OpentelemetryGrpc.setup() + + This is equivalent to calling both `Client.setup()` and `Server.setup()`. + + """ + + @doc """ + Set up both client and server gRPC instrumentation. + + ## Options + + #{NimbleOptions.docs(@options_schema)} + + ## Examples + + # Basic setup + OpentelemetryGrpc.setup() + + # With server options + OpentelemetryGrpc.setup(server: [span_relationship: :link]) + + # Disable client instrumentation + OpentelemetryGrpc.setup(client: :disabled) + + """ + @spec setup(unquote(NimbleOptions.option_typespec(@options_schema))) :: :ok + def setup(opts \\ []) do + config = + opts + |> NimbleOptions.validate!(@options_schema) + |> Enum.into(%{}) + + if config.server != :disabled do + Server.setup(config.server) + end + + if config.client != :disabled do + Client.setup(config.client) + end + + :ok + end +end diff --git a/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/client.ex b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/client.ex new file mode 100644 index 00000000..6a127fcf --- /dev/null +++ b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/client.ex @@ -0,0 +1,166 @@ +defmodule OpentelemetryGrpc.Client do + @moduledoc """ + OpenTelemetry handler for gRPC client telemetry events. + + Attaches telemetry handlers for gRPC client events, automatically creating + OpenTelemetry spans for outgoing RPC calls following OpenTelemetry semantic + conventions. + """ + + alias OpenTelemetry.SemConv.Incubating.RPCAttributes + alias OpenTelemetry.SemConv.NetworkAttributes + alias OpenTelemetry.SemConv.ServerAttributes + alias OpenTelemetry.SemConv.ErrorAttributes + + require OpenTelemetry.Tracer, as: Tracer + + @grpc_client_tracer_id OpentelemetryGrpc.Client + + @client_events [ + [:grpc, :client, :rpc, :start], + [:grpc, :client, :rpc, :stop], + [:grpc, :client, :rpc, :exception] + ] + + @status_codes RPCAttributes.rpc_grpc_status_code_values() + @status_code_mapping %{ + 0 => @status_codes.ok, + 1 => @status_codes.cancelled, + 2 => @status_codes.unknown, + 3 => @status_codes.invalid_argument, + 4 => @status_codes.deadline_exceeded, + 5 => @status_codes.not_found, + 6 => @status_codes.already_exists, + 7 => @status_codes.permission_denied, + 8 => @status_codes.resource_exhausted, + 9 => @status_codes.failed_precondition, + 10 => @status_codes.aborted, + 11 => @status_codes.out_of_range, + 12 => @status_codes.unimplemented, + 13 => @status_codes.internal, + 14 => @status_codes.unavailable, + 15 => @status_codes.data_loss, + 16 => @status_codes.unauthenticated + } + + @doc """ + Set up telemetry handlers for gRPC client events. + + ## Examples + + # Basic setup + OpentelemetryGrpc.Client.setup() + + """ + @spec setup(keyword()) :: :ok + def setup(opts \\ []) do + config = + opts + |> Enum.into(%{}) + + :telemetry.attach_many( + {__MODULE__, :grpc_client_handler}, + @client_events, + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event([:grpc, :client, :rpc, :start], _measurements, metadata, _config) do + span_name = "#{metadata.stream.service_name}/#{metadata.stream.method_name}" + + attributes = + %{ + RPCAttributes.rpc_system() => RPCAttributes.rpc_system_values().grpc, + RPCAttributes.rpc_service() => metadata.stream.service_name, + RPCAttributes.rpc_method() => metadata.stream.method_name, + NetworkAttributes.network_protocol_name() => "http", + NetworkAttributes.network_protocol_version() => "2", + NetworkAttributes.network_transport() => "tcp" + } + |> maybe_add_server_address(metadata.stream) + |> maybe_add_server_port(metadata.stream) + + OpentelemetryTelemetry.start_telemetry_span(@grpc_client_tracer_id, span_name, metadata, %{ + kind: :client, + attributes: attributes + }) + end + + @doc false + def handle_event([:grpc, :client, :rpc, :stop], _measurements, metadata, _config) do + OpentelemetryTelemetry.set_current_telemetry_span(@grpc_client_tracer_id, metadata) + + record_client_span_result(metadata.result) + + OpentelemetryTelemetry.end_telemetry_span(@grpc_client_tracer_id, metadata) + end + + @doc false + def handle_event( + [:grpc, :client, :rpc, :exception], + _measurements, + %{kind: kind, reason: reason, stacktrace: stacktrace} = metadata, + _config + ) do + span_ctx = OpentelemetryTelemetry.set_current_telemetry_span(@grpc_client_tracer_id, metadata) + + OpenTelemetry.Span.record_exception(span_ctx, kind, reason, stacktrace) + + OpenTelemetry.Span.set_status( + span_ctx, + OpenTelemetry.status(:error, Exception.format_banner(kind, reason, stacktrace)) + ) + + OpentelemetryTelemetry.end_telemetry_span(@grpc_client_tracer_id, metadata) + end + + defp record_client_span_result({:ok, response, _metadata}) do + # The 3-tuple format returned when return_headers: true option is used. + record_client_span_result({:ok, response}) + end + + defp record_client_span_result({:ok, _response}) do + Tracer.set_attributes(%{ + RPCAttributes.rpc_grpc_status_code() => @status_codes.ok + }) + + Tracer.set_status(OpenTelemetry.status(:ok)) + end + + defp record_client_span_result({:error, %GRPC.RPCError{status: status, message: message}}) do + Tracer.set_attributes(%{ + RPCAttributes.rpc_grpc_status_code() => Map.get(@status_code_mapping, status, status), + ErrorAttributes.error_type() => "grpc_error" + }) + + otel_status = grpc_status_to_otel_status(status) + status_name = GRPC.Status.code_name(status) + + Tracer.set_status(otel_status, message || "gRPC error: #{status_name}") + end + + defp record_client_span_result({:error, error}) do + Tracer.set_status(:error, inspect(error)) + end + + defp record_client_span_result(_result), do: :ok + + defp maybe_add_server_address(attrs, %{channel: %{host: host}}) + when is_binary(host) and host != "unknown" do + Map.put(attrs, ServerAttributes.server_address(), host) + end + + defp maybe_add_server_address(attrs, _), do: attrs + + defp maybe_add_server_port(attrs, %{channel: %{port: port}}) + when is_integer(port) and port > 0 do + Map.put(attrs, ServerAttributes.server_port(), port) + end + + defp maybe_add_server_port(attrs, _), do: attrs + + defp grpc_status_to_otel_status(0), do: :ok + defp grpc_status_to_otel_status(_), do: :error +end diff --git a/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/context_propagator_interceptor.ex b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/context_propagator_interceptor.ex new file mode 100644 index 00000000..6e86be5d --- /dev/null +++ b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/context_propagator_interceptor.ex @@ -0,0 +1,50 @@ +defmodule OpentelemetryGrpc.ContextPropagatorInterceptor do + @moduledoc """ + gRPC client interceptor for OpenTelemetry context propagation. + + This interceptor injects the current OpenTelemetry trace context into outgoing + gRPC client requests by adding distributed tracing headers to the gRPC metadata. + This enables trace continuity across service boundaries in distributed systems. + + ## Usage + + Add this interceptor to your gRPC client channel: + + {:ok, channel} = GRPC.Stub.connect("localhost:50051", + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor]) + + ## Interceptor Ordering + + This interceptor can be placed anywhere in the interceptor pipeline. In practice, + the position shouldn't matter as long as other interceptors don't drop or modify + the injected tracing headers. When in doubt, place it at the end of the pipeline + to ensure the headers are added after any other metadata modifications. + + ### Example with Multiple Interceptors + + {:ok, channel} = GRPC.Stub.connect("localhost:50051", + interceptors: [ + MyCustomInterceptor, + OpentelemetryGrpc.ContextPropagatorInterceptor # Safe at the end + ]) + + """ + + alias GRPC.Client.Stream + + @behaviour GRPC.Client.Interceptor + + @impl GRPC.Client.Interceptor + def init(opts), do: opts + + @impl GRPC.Client.Interceptor + def call(stream, req, next, _opts) do + metadata = + :opentelemetry.get_text_map_injector() + |> :otel_propagator_text_map.inject(%{}, &Map.put(&3, &1, &2)) + + stream + |> Stream.put_headers(metadata) + |> next.(req) + end +end diff --git a/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/server.ex b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/server.ex new file mode 100644 index 00000000..0ddd69f7 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/lib/opentelemetry_grpc/server.ex @@ -0,0 +1,271 @@ +defmodule OpentelemetryGrpc.Server do + alias OpenTelemetry.SemConv.ErrorAttributes + alias OpenTelemetry.SemConv.Incubating.RPCAttributes + alias OpenTelemetry.SemConv.NetworkAttributes + + require OpenTelemetry.Tracer, as: Tracer + require Logger + + @options_schema [ + span_relationship: [ + type: {:in, [:child, :link, :none]}, + default: :child, + doc: """ + How spans relate to propagated parent context: + * `:child` - Extract context and create parent-child relationships (default) + * `:link` - Extract context and create span links for loose coupling + * `:none` - Disable context propagation entirely + """ + ] + ] + + @nimble_options_schema NimbleOptions.new!(@options_schema) + + @doc false + def options_schema do + @options_schema + end + + @moduledoc """ + OpenTelemetry handler for gRPC server telemetry events. + + This module handles server-side gRPC telemetry events and creates + OpenTelemetry spans for incoming gRPC requests with proper context propagation. + + ## Usage + + # Basic setup with defaults + OpentelemetryGrpc.Server.setup() + + # Custom configuration + OpentelemetryGrpc.Server.setup(span_relationship: :child) + """ + + @grpc_server_tracer_id OpentelemetryGrpc.Server + + @server_events [ + [:grpc, :server, :rpc, :start], + [:grpc, :server, :rpc, :stop], + [:grpc, :server, :rpc, :exception] + ] + @grpc_rpc_system RPCAttributes.rpc_system_values().grpc + @status_codes RPCAttributes.rpc_grpc_status_code_values() + @status_code_mapping %{ + 0 => @status_codes.ok, + 1 => @status_codes.cancelled, + 2 => @status_codes.unknown, + 3 => @status_codes.invalid_argument, + 4 => @status_codes.deadline_exceeded, + 5 => @status_codes.not_found, + 6 => @status_codes.already_exists, + 7 => @status_codes.permission_denied, + 8 => @status_codes.resource_exhausted, + 9 => @status_codes.failed_precondition, + 10 => @status_codes.aborted, + 11 => @status_codes.out_of_range, + 12 => @status_codes.unimplemented, + 13 => @status_codes.internal, + 14 => @status_codes.unavailable, + 15 => @status_codes.data_loss, + 16 => @status_codes.unauthenticated + } + @status_code_ok RPCAttributes.rpc_grpc_status_code_values().ok + @status_code_internal RPCAttributes.rpc_grpc_status_code_values().internal + + @doc """ + Set up telemetry handlers for gRPC server events. + + ## Options + + #{NimbleOptions.docs(@nimble_options_schema)} + + ## Examples + + # Default setup + OpentelemetryGrpc.Server.setup() + + # Custom configuration + OpentelemetryGrpc.Server.setup(span_relationship: :link) + + """ + @spec setup(unquote(NimbleOptions.option_typespec(@options_schema))) :: :ok + def setup(opts \\ []) do + config = + opts + |> NimbleOptions.validate!(@nimble_options_schema) + |> Enum.into(%{}) + + :telemetry.attach_many( + {__MODULE__, :grpc_server_handler}, + @server_events, + &__MODULE__.handle_event/4, + config + ) + end + + @doc false + def handle_event([:grpc, :server, :rpc, :start], _measurements, metadata, config) do + span_opts = %{kind: :server, attributes: build_server_attributes(metadata)} + links = setup_context_propagation(metadata.stream, config.span_relationship) + span_opts = put_links(span_opts, links) + + OpentelemetryTelemetry.start_telemetry_span( + @grpc_server_tracer_id, + "#{metadata.stream.service_name}/#{metadata.stream.method_name}", + metadata, + span_opts + ) + end + + @doc false + def handle_event([:grpc, :server, :rpc, :stop], _measurements, metadata, _config) do + OpentelemetryTelemetry.set_current_telemetry_span(@grpc_server_tracer_id, metadata) + record_server_span_result(metadata.result) + OpentelemetryTelemetry.end_telemetry_span(@grpc_server_tracer_id, metadata) + end + + @doc false + def handle_event([:grpc, :server, :rpc, :exception], _measurements, metadata, _config) do + ctx = OpentelemetryTelemetry.set_current_telemetry_span(@grpc_server_tracer_id, metadata) + + OpenTelemetry.Span.record_exception(ctx, metadata.kind, metadata.reason, metadata.stacktrace) + + record_server_span_result({:error, metadata.reason}) + + OpenTelemetry.Span.set_status( + ctx, + OpenTelemetry.status( + :error, + Exception.format_banner(metadata.kind, metadata.reason, metadata.stacktrace) + ) + ) + + OpentelemetryTelemetry.end_telemetry_span(@grpc_server_tracer_id, metadata) + end + + defp build_server_attributes(metadata) do + %{ + RPCAttributes.rpc_system() => @grpc_rpc_system, + RPCAttributes.rpc_service() => metadata.stream.service_name, + RPCAttributes.rpc_method() => metadata.stream.method_name, + NetworkAttributes.network_protocol_name() => "http", + NetworkAttributes.network_protocol_version() => "2", + NetworkAttributes.network_transport() => "tcp", + "elixir.grpc.server_module" => to_string(metadata.server) + } + |> put_endpoint_module_attr(metadata.endpoint) + end + + defp put_endpoint_module_attr(attrs, nil) do + attrs + end + + defp put_endpoint_module_attr(attrs, endpoint) do + Map.put(attrs, "elixir.grpc.endpoint_module", to_string(endpoint)) + end + + defp record_server_span_result({:ok, _stream, _response}) do + Tracer.set_attributes(%{ + RPCAttributes.rpc_grpc_status_code() => @status_code_ok + }) + + Tracer.set_status(OpenTelemetry.status(:ok)) + end + + defp record_server_span_result({:error, %GRPC.RPCError{status: status, message: message}}) do + Tracer.set_attributes(%{ + RPCAttributes.rpc_grpc_status_code() => Map.get(@status_code_mapping, status, status), + ErrorAttributes.error_type() => to_string(GRPC.RPCError) + }) + + status_name = GRPC.Status.code_name(status) + Tracer.set_status(OpenTelemetry.status(:error, message || "gRPC error: #{status_name}")) + end + + defp record_server_span_result({:error, %error_struct{} = error}) when is_exception(error) do + Tracer.set_attributes(%{ + RPCAttributes.rpc_grpc_status_code() => @status_code_internal, + ErrorAttributes.error_type() => to_string(error_struct) + }) + + Tracer.set_status(OpenTelemetry.status(:error, Exception.message(error))) + end + + defp record_server_span_result(_result) do + # Unknown result format - don't set status + :ok + end + + defp put_links(span_opts, []) do + span_opts + end + + defp put_links(span_opts, links) do + Map.put(span_opts, :links, links) + end + + defp setup_context_propagation(source, :child) do + extract_and_attach(source) + end + + defp setup_context_propagation(source, :link) do + link_from_propagated_ctx(source) + end + + defp setup_context_propagation(_source, _) do + [] + end + + defp extract_and_attach(source) do + case get_propagated_ctx(source) do + {_links, parent_ctx} when parent_ctx != :undefined -> + OpenTelemetry.Ctx.attach(parent_ctx) + + # When we attach the context, we don't need links - parent-child relationship is established + [] + + {links, _undefined_ctx} -> + # No parent context to attach, but we can still return links if any + links + end + end + + defp link_from_propagated_ctx(source) do + {links, _ctx} = get_propagated_ctx(source) + links + end + + defp get_propagated_ctx(stream) do + stream + |> get_grpc_headers() + |> extract_to_ctx() + end + + defp extract_to_ctx([]) do + {[], :undefined} + end + + defp extract_to_ctx(headers) do + ctx = + OpenTelemetry.Ctx.new() + |> :otel_propagator_text_map.extract_to(headers) + + # Extract span context to check if it's valid and for creating links + span_ctx = OpenTelemetry.Tracer.current_span_ctx(ctx) + + case span_ctx do + :undefined -> + # No valid parent span - no relationship possible + {[], :undefined} + + span_ctx -> + # Return links first, then context (for parent-child relationships) + {[OpenTelemetry.link(span_ctx)], ctx} + end + end + + defp get_grpc_headers(%GRPC.Server.Stream{http_request_headers: headers}) when is_map(headers), + do: Map.to_list(headers) + + defp get_grpc_headers(_stream), do: [] +end diff --git a/instrumentation/opentelemetry_grpc/mix.exs b/instrumentation/opentelemetry_grpc/mix.exs new file mode 100644 index 00000000..d712dfe0 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/mix.exs @@ -0,0 +1,88 @@ +defmodule OpentelemetryGrpc.MixProject do + use Mix.Project + + @version "1.0.0" + + def project do + [ + app: :opentelemetry_grpc, + description: description(), + version: @version, + elixir: "~> 1.11", + start_permanent: Mix.env() == :prod, + dialyzer: [ + plt_add_apps: [:ex_unit, :mix], + plt_core_path: "priv/plts", + plt_local_path: "priv/plts" + ], + deps: deps(), + name: "Opentelemetry gRPC", + docs: [ + main: "OpentelemetryGrpc", + source_url_pattern: + "https://github.com/open-telemetry/opentelemetry-erlang-contrib/blob/main/instrumentation/opentelemetry_grpc/%{path}#L%{line}", + extras: ["README.md"] + ], + elixirc_paths: elixirc_paths(Mix.env()), + package: package(), + source_url: + "https://github.com/open-telemetry/opentelemetry-erlang-contrib/tree/main/instrumentation/opentelemetry_grpc", + aliases: aliases() + ] + end + + def application do + [ + extra_applications: [] + ] + end + + defp description do + "Trace gRPC requests and responses with OpenTelemetry." + end + + defp package do + [ + description: "OpenTelemetry tracing for gRPC", + 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_grpc", + "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 + + defp elixirc_paths(:test), do: ["lib", "test/support", "test/support/proto"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:nimble_options, "~> 1.1"}, + {:opentelemetry_api, "~> 1.4"}, + {:opentelemetry_telemetry, "~> 1.1"}, + {:opentelemetry_semantic_conventions, "~> 1.27"}, + {:telemetry, "~> 1.0"}, + {:grpc, "~> 0.8"}, + {:protobuf, "~> 0.15"}, + {:opentelemetry_exporter, "~> 1.8", only: [:dev, :test]}, + {:opentelemetry, "~> 1.5", only: [:dev, :test]}, + {:ex_doc, "~> 0.38", only: [:dev], runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + "protoc.compile": [ + "cmd protoc --elixir_out=plugins=grpc:test/support/proto --proto_path=proto proto/test_service.proto" + ], + test: ["test --warnings-as-errors --trace"] + ] + end +end diff --git a/instrumentation/opentelemetry_grpc/mix.lock b/instrumentation/opentelemetry_grpc/mix.lock new file mode 100644 index 00000000..ed946096 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/mix.lock @@ -0,0 +1,37 @@ +%{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, + "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, + "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, + "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"}, + "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, + "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, + "grpc": {:hex, :grpc, "0.10.2", "e67f965c720f05fe706e403ee980c6b4e0310cd4443cecd6361845102e2a16cb", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:cowboy, "~> 2.10", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowlib, "~> 2.12", [hex: :cowlib, repo: "hexpm", optional: false]}, {:flow, "~> 1.2", [hex: :flow, repo: "hexpm", optional: false]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.14", [hex: :protobuf, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f3f5c48a655633551a49df8f3f90e772e7a6e26765ebe109bcb5c22cd6b55dce"}, + "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, + "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, + "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "opentelemetry": {:hex, :opentelemetry, "1.5.1", "f2a6b1ecd7cf252aa968beceb2c51cad03965c0984764af263f73b49203269ea", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "27c6775b2b609bb28bd9c1c0cb2dee907bfed2e31fcf0afd9b8e3fad27ef1382"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.1", "b88188fe1390ce30fd45d6a9d77018262875759aac3b387d82ea12a59078f006", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "0a64b2889aa87f38f0b3afcebe1f0a50c52b7e956fe6e535668741561c753e97"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, + "protobuf": {:hex, :protobuf, "0.15.0", "c9fc1e9fc1682b05c601df536d5ff21877b55e2023e0466a3855cc1273b74dcb", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5d7bb325319db1d668838d2691c31c7b793c34111aec87d5ee467a39dac6e051"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, +} diff --git a/instrumentation/opentelemetry_grpc/priv/plts/.gitignore b/instrumentation/opentelemetry_grpc/priv/plts/.gitignore new file mode 100644 index 00000000..72e8ffc0 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/priv/plts/.gitignore @@ -0,0 +1 @@ +* diff --git a/instrumentation/opentelemetry_grpc/proto/test_service.proto b/instrumentation/opentelemetry_grpc/proto/test_service.proto new file mode 100644 index 00000000..325cd05c --- /dev/null +++ b/instrumentation/opentelemetry_grpc/proto/test_service.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package testserver.v1; + +// Test service for OpenTelemetry gRPC instrumentation testing +service TestService { + // Simple unary RPC + rpc SayHello(HelloRequest) returns (HelloResponse); + + // Server streaming RPC + rpc ListNumbers(NumberRequest) returns (stream NumberResponse); +} + +message HelloRequest { + string name = 1; +} + +message HelloResponse { + string message = 1; +} + +message NumberRequest { + int32 count = 1; + int32 number = 2; +} + +message NumberResponse { + int32 number = 1; +} diff --git a/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/client_test.exs b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/client_test.exs new file mode 100644 index 00000000..94774a9e --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/client_test.exs @@ -0,0 +1,227 @@ +defmodule OpentelemetryGrpc.ClientTest do + use ExUnit.Case, async: false + doctest OpentelemetryGrpc.Client + + require OpenTelemetry.Tracer + require OpenTelemetry.Span + require Record + + alias OpentelemetryGrpc.TestSupport + alias Testserver.V1.{TestService, HelloRequest} + + 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 + + @test_port 50053 + + setup_all do + Application.ensure_all_started(:telemetry) + Application.ensure_all_started(:grpc) + + port = TestSupport.start_server(@test_port) + + on_exit(fn -> + TestSupport.stop_server(port) + end) + + {:ok, port: port} + end + + setup do + :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) + + OpentelemetryGrpc.Client.setup() + + on_exit(fn -> + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + end) + + :ok + end + + describe "setup/0" do + test "attaches telemetry handlers for client events" do + assert handlers = :telemetry.list_handlers([]) + + client_events = [ + [:grpc, :client, :rpc, :start], + [:grpc, :client, :rpc, :stop], + [:grpc, :client, :rpc, :exception] + ] + + for event <- client_events do + assert Enum.any?(handlers, &match?(%{event_name: ^event}, &1)) + end + end + end + + describe "integration tests" do + test "records span on successful gRPC client requests", %{port: port} do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + request = %HelloRequest{name: "ClientTest"} + + {:ok, response} = TestService.Stub.say_hello(channel, request) + assert response.message == "Hello, ClientTest!" + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :client, + attributes: attributes, + status: status + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"server.address" => "127.0.0.1", + :"server.port" => port, + :"rpc.grpc.status_code" => 0 + } + + assert status == OpenTelemetry.status(:ok) + + TestSupport.disconnect_client(channel) + end + + test "records span on successful gRPC client requests with return_headers: true", %{ + port: port + } do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + request = %HelloRequest{name: "ClientTest"} + + # Test that the implementation correctly handles the 3-tuple format returned + # when return_headers: true option is used. The gRPC stub returns + # {:ok, response, metadata} instead of {:ok, response}, and we need to + # ensure the status code is still properly recorded. + {:ok, response, metadata} = + TestService.Stub.say_hello(channel, request, return_headers: true) + + assert response.message == "Hello, ClientTest!" + assert is_map(metadata.headers) + assert is_map(metadata.trailers) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :client, + attributes: attributes, + status: status + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"server.address" => "127.0.0.1", + :"server.port" => port, + :"rpc.grpc.status_code" => 0 + } + + assert status == OpenTelemetry.status(:ok) + + TestSupport.disconnect_client(channel) + end + end + + describe "error handling" do + test "records span with GRPC.RPCError (not_found)", %{port: port} do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + request = %HelloRequest{name: "TriggerNotFound"} + + assert {:error, _error} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :client, + attributes: attributes, + status: status + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"server.address" => "127.0.0.1", + :"server.port" => port, + :"rpc.grpc.status_code" => 5, + :"error.type" => "grpc_error" + } + + assert status == OpenTelemetry.status(:error, "User not found") + + TestSupport.disconnect_client(channel) + end + + test "records span with GRPC.RPCError (invalid_argument)", %{port: port} do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + request = %HelloRequest{name: "TriggerInvalidArgument"} + + assert {:error, _error} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :client, + attributes: attributes, + status: status + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"server.address" => "127.0.0.1", + :"server.port" => port, + :"rpc.grpc.status_code" => 3, + :"error.type" => "grpc_error" + } + + assert status == OpenTelemetry.status(:error, "Invalid name format") + + TestSupport.disconnect_client(channel) + end + end +end diff --git a/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/context_propagator_interceptor_test.exs b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/context_propagator_interceptor_test.exs new file mode 100644 index 00000000..7569a2ac --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/context_propagator_interceptor_test.exs @@ -0,0 +1,58 @@ +defmodule OpentelemetryGrpc.ContextPropagatorInterceptorTest do + use ExUnit.Case, async: false + doctest OpentelemetryGrpc.ContextPropagatorInterceptor + + require OpenTelemetry.Tracer + require OpenTelemetry.Span + + alias OpentelemetryGrpc.ContextPropagatorInterceptor + alias Testserver.V1.HelloRequest + + setup do + :application.stop(:opentelemetry) + :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) + + :application.set_env(:opentelemetry, :processors, [ + {:otel_batch_processor, %{scheduled_delay_ms: 1}} + ]) + + :application.start(:opentelemetry) + + :ok + end + + describe "init/1" do + test "returns options unchanged" do + opts = [some: "option", other: 123] + assert ContextPropagatorInterceptor.init(opts) == opts + end + + test "returns empty list for no options" do + assert ContextPropagatorInterceptor.init([]) == [] + end + end + + describe "call/4" do + test "calls next function with stream" do + stream = %GRPC.Client.Stream{} + request = %HelloRequest{name: "test"} + + result = ContextPropagatorInterceptor.call(stream, request, next_ok(), []) + + assert {:ok, %GRPC.Client.Stream{}} = result + end + + test "preserves existing headers" do + existing_headers = %{"existing" => "header", "another" => "value"} + stream = %GRPC.Client.Stream{headers: existing_headers} + request = %HelloRequest{name: "test"} + + result = ContextPropagatorInterceptor.call(stream, request, next_ok(), []) + + assert {:ok, modified_stream} = result + assert modified_stream.headers == existing_headers + end + end + + defp next_ok, do: fn stream_arg, _request -> {:ok, stream_arg} end +end diff --git a/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/server_test.exs b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/server_test.exs new file mode 100644 index 00000000..20f5a6b0 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc/server_test.exs @@ -0,0 +1,277 @@ +defmodule OpentelemetryGrpc.ServerTest do + use ExUnit.Case, async: false + doctest OpentelemetryGrpc.Server + + require OpenTelemetry.Tracer + require OpenTelemetry.Span + require Record + + alias OpentelemetryGrpc.TestSupport + alias Testserver.V1.{TestService, HelloRequest} + alias OpenTelemetry.SemConv.Incubating.RPCAttributes + + 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 + + @test_port 50054 + + @grpc_status_ok RPCAttributes.rpc_grpc_status_code_values().ok + @grpc_status_not_found RPCAttributes.rpc_grpc_status_code_values().not_found + @grpc_status_internal RPCAttributes.rpc_grpc_status_code_values().internal + @grpc_status_invalid_argument RPCAttributes.rpc_grpc_status_code_values().invalid_argument + + setup_all do + Application.ensure_all_started(:telemetry) + Application.ensure_all_started(:grpc) + + port = TestSupport.start_server(@test_port) + + on_exit(fn -> + TestSupport.stop_server(port) + end) + + {:ok, port: port} + end + + setup do + :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) + + OpentelemetryGrpc.Server.setup() + + on_exit(fn -> + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + end) + + :ok + end + + describe "setup/1" do + setup do + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + + on_exit(fn -> + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + end) + + :ok + end + + test "sets up with default options" do + :ok = OpentelemetryGrpc.Server.setup() + + assert handlers = :telemetry.list_handlers([]) + + server_events = [ + [:grpc, :server, :rpc, :start], + [:grpc, :server, :rpc, :stop], + [:grpc, :server, :rpc, :exception] + ] + + for event <- server_events do + assert Enum.any?(handlers, &match?(%{event_name: ^event}, &1)) + end + end + + test "sets up with child span relationship" do + :ok = OpentelemetryGrpc.Server.setup(span_relationship: :child) + + assert handlers = :telemetry.list_handlers([]) + + assert Enum.any?( + handlers, + &match?( + %{ + event_name: [:grpc, :server, :rpc, :start], + config: %{span_relationship: :child} + }, + &1 + ) + ) + end + + test "sets up with link span relationship" do + :ok = OpentelemetryGrpc.Server.setup(span_relationship: :link) + + assert handlers = :telemetry.list_handlers([]) + + assert Enum.any?( + handlers, + &match?( + %{ + event_name: [:grpc, :server, :rpc, :start], + config: %{span_relationship: :link} + }, + &1 + ) + ) + end + + test "sets up with none span relationship" do + :ok = OpentelemetryGrpc.Server.setup(span_relationship: :none) + + assert handlers = :telemetry.list_handlers([]) + + assert Enum.any?( + handlers, + &match?( + %{ + event_name: [:grpc, :server, :rpc, :start], + config: %{span_relationship: :none} + }, + &1 + ) + ) + end + + test "raises on invalid span_relationship option" do + assert_raise NimbleOptions.ValidationError, fn -> + OpentelemetryGrpc.Server.setup(span_relationship: :invalid) + end + end + end + + describe "integration tests" do + test "records span on gRPC server requests", %{port: port} do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + request = %HelloRequest{name: "ServerTest"} + + assert {:ok, _response} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :server, + attributes: attributes, + status: status + )} + + assert status == OpenTelemetry.status(:ok) + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"rpc.grpc.status_code" => @grpc_status_ok, + "elixir.grpc.server_module" => "Elixir.OpentelemetryGrpc.Test.TestServer" + } + + TestSupport.disconnect_client(channel) + end + end + + describe "error handling" do + setup %{port: port} do + {:ok, channel} = + TestSupport.connect_client(port, + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + + on_exit(fn -> TestSupport.disconnect_client(channel) end) + + {:ok, channel: channel} + end + + test "records span with GRPC.RPCError (not_found)", %{channel: channel} do + request = %HelloRequest{name: "TriggerNotFound"} + + assert {:error, _error} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :server, + status: status, + attributes: attributes + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"rpc.grpc.status_code" => @grpc_status_not_found, + :"error.type" => "Elixir.GRPC.RPCError", + "elixir.grpc.server_module" => "Elixir.OpentelemetryGrpc.Test.TestServer" + } + + assert status == OpenTelemetry.status(:error, "User not found") + end + + test "records span with GRPC.RPCError (invalid_argument)", %{channel: channel} do + request = %HelloRequest{name: "TriggerInvalidArgument"} + + assert {:error, _error} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :server, + status: status, + attributes: attributes + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"rpc.grpc.status_code" => @grpc_status_invalid_argument, + :"error.type" => "Elixir.GRPC.RPCError", + "elixir.grpc.server_module" => "Elixir.OpentelemetryGrpc.Test.TestServer" + } + + assert status == OpenTelemetry.status(:error, "Invalid name format") + end + + test "records span with exception", %{channel: channel} do + request = %HelloRequest{name: "TriggerException"} + + assert {:error, _error} = TestService.Stub.say_hello(channel, request) + + assert_receive {:span, + span( + name: "testserver.v1.TestService/SayHello", + kind: :server, + status: status, + attributes: attributes + )} + + assert :otel_attributes.map(attributes) == %{ + :"rpc.system" => :grpc, + :"rpc.service" => "testserver.v1.TestService", + :"rpc.method" => "SayHello", + :"network.protocol.name" => "http", + :"network.protocol.version" => "2", + :"network.transport" => "tcp", + :"rpc.grpc.status_code" => @grpc_status_internal, + :"error.type" => "Elixir.RuntimeError", + "elixir.grpc.server_module" => "Elixir.OpentelemetryGrpc.Test.TestServer" + } + + assert status == OpenTelemetry.status(:error, "Server crashed") + end + end +end diff --git a/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc_test.exs b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc_test.exs new file mode 100644 index 00000000..80b80fa7 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/opentelemetry_grpc_test.exs @@ -0,0 +1,134 @@ +defmodule OpentelemetryGrpcTest do + use ExUnit.Case + + @server_events [ + [:grpc, :server, :rpc, :start], + [:grpc, :server, :rpc, :stop], + [:grpc, :server, :rpc, :exception] + ] + + @client_events [ + [:grpc, :client, :rpc, :start], + [:grpc, :client, :rpc, :stop], + [:grpc, :client, :rpc, :exception] + ] + + @all_events @client_events ++ @server_events + @server_start_event List.first(@server_events) + + doctest OpentelemetryGrpc + + describe "module structure" do + test "all required modules exist" do + assert Code.ensure_loaded?(OpentelemetryGrpc) + assert Code.ensure_loaded?(OpentelemetryGrpc.Client) + assert Code.ensure_loaded?(OpentelemetryGrpc.Server) + assert Code.ensure_loaded?(OpentelemetryGrpc.ContextPropagatorInterceptor) + end + end + + describe "setup/1" do + setup do + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + + on_exit(fn -> + for h <- :telemetry.list_handlers([]), do: :telemetry.detach(h.id) + end) + + :ok + end + + test "calling setup twice is safe (idempotent or explicit)" do + :ok = OpentelemetryGrpc.setup() + assert :ok = OpentelemetryGrpc.setup() + + assert handlers = :telemetry.list_handlers([]) + assert length(handlers) == length(@all_events) + end + + test "sets up both client and server instrumentation by default" do + :ok = OpentelemetryGrpc.setup() + + assert handlers = :telemetry.list_handlers([]) + + for event <- @all_events do + assert Enum.any?(handlers, &match?(%{event_name: ^event}, &1)), + "Missing handler for event: #{inspect(event)}" + end + end + + test "can disable client instrumentation" do + :ok = OpentelemetryGrpc.setup(client: :disabled) + assert handlers = :telemetry.list_handlers([]) + + for event <- @server_events do + assert Enum.any?(handlers, &match?(%{event_name: ^event}, &1)), + "Missing server handler for event: #{inspect(event)}" + end + + for event <- @client_events do + refute Enum.any?(handlers, &match?(%{event_name: ^event}, &1)), + "Should not have client handler for event: #{inspect(event)}" + end + end + + test "can disable server instrumentation" do + :ok = OpentelemetryGrpc.setup(server: :disabled) + + assert handlers = :telemetry.list_handlers([]) + + for event <- @client_events do + assert Enum.any?(handlers, &match?(%{event_name: ^event}, &1)), + "Missing client handler for event: #{inspect(event)}" + end + + for event <- @server_events do + refute Enum.any?(handlers, &match?(%{event_name: ^event}, &1)), + "Should not have server handler for event: #{inspect(event)}" + end + end + + test "can configure server span relationship" do + :ok = OpentelemetryGrpc.setup(server: [span_relationship: :link]) + + assert handlers = :telemetry.list_handlers([]) + + server_start_handler = + Enum.find(handlers, &match?(%{event_name: @server_start_event}, &1)) + + assert server_start_handler + assert server_start_handler.config.span_relationship == :link + end + + test "can disable both client and server instrumentation" do + :ok = OpentelemetryGrpc.setup(client: :disabled, server: :disabled) + + assert handlers = :telemetry.list_handlers([]) + + grpc_handlers = Enum.filter(handlers, &match?(%{event_name: [:grpc | _]}, &1)) + + assert grpc_handlers == [] + end + + test "raises on invalid server option" do + assert_raise NimbleOptions.ValidationError, fn -> + OpentelemetryGrpc.setup(server: :invalid) + end + end + + test "accepts valid client options" do + :ok = OpentelemetryGrpc.setup() + :ok = OpentelemetryGrpc.setup(client: :disabled) + end + + test "raises on invalid client option" do + assert_raise NimbleOptions.ValidationError, fn -> + OpentelemetryGrpc.setup(client: :invalid) + end + + assert_raise NimbleOptions.ValidationError, fn -> + OpentelemetryGrpc.setup(client: true) + end + end + end +end diff --git a/instrumentation/opentelemetry_grpc/test/support/proto/test_service.pb.ex b/instrumentation/opentelemetry_grpc/test/support/proto/test_service.pb.ex new file mode 100644 index 00000000..2bbb4e87 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/support/proto/test_service.pb.ex @@ -0,0 +1,48 @@ +defmodule Testserver.V1.HelloRequest do + @moduledoc false + + use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto3 + + field(:name, 1, type: :string) +end + +defmodule Testserver.V1.HelloResponse do + @moduledoc false + + use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto3 + + field(:message, 1, type: :string) +end + +defmodule Testserver.V1.NumberRequest do + @moduledoc false + + use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto3 + + field(:count, 1, type: :int32) + field(:number, 2, type: :int32) +end + +defmodule Testserver.V1.NumberResponse do + @moduledoc false + + use Protobuf, protoc_gen_elixir_version: "0.15.0", syntax: :proto3 + + field(:number, 1, type: :int32) +end + +defmodule Testserver.V1.TestService.Service do + @moduledoc false + + use GRPC.Service, name: "testserver.v1.TestService", protoc_gen_elixir_version: "0.15.0" + + rpc(:SayHello, Testserver.V1.HelloRequest, Testserver.V1.HelloResponse) + + rpc(:ListNumbers, Testserver.V1.NumberRequest, stream(Testserver.V1.NumberResponse)) +end + +defmodule Testserver.V1.TestService.Stub do + @moduledoc false + + use GRPC.Stub, service: Testserver.V1.TestService.Service +end diff --git a/instrumentation/opentelemetry_grpc/test/support/test_server.ex b/instrumentation/opentelemetry_grpc/test/support/test_server.ex new file mode 100644 index 00000000..652c7dec --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/support/test_server.ex @@ -0,0 +1,32 @@ +defmodule OpentelemetryGrpc.Test.TestServer do + use GRPC.Server, service: Testserver.V1.TestService.Service + + alias Testserver.V1.{ + HelloResponse, + NumberResponse + } + + def say_hello(request, _stream) do + case request.name do + "TriggerNotFound" -> + raise GRPC.RPCError, status: :not_found, message: "User not found" + + "TriggerInvalidArgument" -> + raise GRPC.RPCError, status: :invalid_argument, message: "Invalid name format" + + "TriggerException" -> + raise RuntimeError, "Server crashed" + + _ -> + %HelloResponse{message: "Hello, #{request.name}!"} + end + end + + def list_numbers(request, stream) do + 1..request.count + |> Enum.each(fn i -> + response = %NumberResponse{number: i * request.number} + GRPC.Server.send_reply(stream, response) + end) + end +end diff --git a/instrumentation/opentelemetry_grpc/test/support/test_support.ex b/instrumentation/opentelemetry_grpc/test/support/test_support.ex new file mode 100644 index 00000000..473180d9 --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/support/test_support.ex @@ -0,0 +1,35 @@ +defmodule OpentelemetryGrpc.TestSupport do + @default_port 42069 + + def start_server(port \\ @default_port) do + {:ok, _pid, actual_port} = + GRPC.Server.start( + OpentelemetryGrpc.Test.TestServer, + port, + adapter: GRPC.Server.Adapters.Cowboy + ) + + actual_port + end + + def stop_server(_port) do + GRPC.Server.stop(OpentelemetryGrpc.Test.TestServer, adapter: GRPC.Server.Adapters.Cowboy) + end + + def connect_client(port \\ @default_port) do + GRPC.Stub.connect("127.0.0.1:#{port}", + interceptors: [OpentelemetryGrpc.ContextPropagatorInterceptor] + ) + end + + def connect_client(port, opts) when is_integer(port) do + interceptors = + Keyword.get(opts, :interceptors, [OpentelemetryGrpc.ContextPropagatorInterceptor]) + + GRPC.Stub.connect("127.0.0.1:#{port}", interceptors: interceptors) + end + + def disconnect_client(channel) do + GRPC.Stub.disconnect(channel) + end +end diff --git a/instrumentation/opentelemetry_grpc/test/test_helper.exs b/instrumentation/opentelemetry_grpc/test/test_helper.exs new file mode 100644 index 00000000..038c845c --- /dev/null +++ b/instrumentation/opentelemetry_grpc/test/test_helper.exs @@ -0,0 +1,4 @@ +Application.ensure_all_started(:opentelemetry) +Application.ensure_all_started(:opentelemetry_api) + +ExUnit.start()