From db5193be45f21a8eef27a81457df57614fdc484c Mon Sep 17 00:00:00 2001 From: Tristan Sloughter Date: Sun, 27 Mar 2022 11:02:44 -0600 Subject: [PATCH] elli instrumentation library (#69) * elli instrumentation library * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * Update instrumentation/opentelemetry_elli/README.md Co-authored-by: Fred Hebert * add note about how excluded URL env var is parsed * Update instrumentation/opentelemetry_elli/src/opentelemetry_elli.app.src Co-authored-by: Fred Hebert Co-authored-by: Fred Hebert --- instrumentation/opentelemetry_elli/LICENSE | 191 +++++++++++++++ instrumentation/opentelemetry_elli/README.md | 94 ++++++++ .../opentelemetry_elli/rebar.config | 16 ++ instrumentation/opentelemetry_elli/rebar.lock | 11 + .../src/opentelemetry_elli.app.src | 16 ++ .../opentelemetry_elli/src/otel_elli.erl | 113 +++++++++ .../src/otel_elli_middleware.erl | 190 +++++++++++++++ .../test/otel_elli_SUITE.erl | 223 ++++++++++++++++++ 8 files changed, 854 insertions(+) create mode 100644 instrumentation/opentelemetry_elli/LICENSE create mode 100644 instrumentation/opentelemetry_elli/README.md create mode 100644 instrumentation/opentelemetry_elli/rebar.config create mode 100644 instrumentation/opentelemetry_elli/rebar.lock create mode 100644 instrumentation/opentelemetry_elli/src/opentelemetry_elli.app.src create mode 100644 instrumentation/opentelemetry_elli/src/otel_elli.erl create mode 100644 instrumentation/opentelemetry_elli/src/otel_elli_middleware.erl create mode 100644 instrumentation/opentelemetry_elli/test/otel_elli_SUITE.erl diff --git a/instrumentation/opentelemetry_elli/LICENSE b/instrumentation/opentelemetry_elli/LICENSE new file mode 100644 index 0000000..a5b3855 --- /dev/null +++ b/instrumentation/opentelemetry_elli/LICENSE @@ -0,0 +1,191 @@ + 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 + + Copyright 2020, Tristan Sloughter . + + 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_elli/README.md b/instrumentation/opentelemetry_elli/README.md new file mode 100644 index 0000000..7923ada --- /dev/null +++ b/instrumentation/opentelemetry_elli/README.md @@ -0,0 +1,94 @@ +# opentelemetry_elli + +![Common Test](https://github.com/opentelemetry-beam/opentelemetry_elli/workflows/Common%20Test/badge.svg) [![Gitter](https://badges.gitter.im/open-telemetry/opentelemetry-erlang.svg)](https://gitter.im/open-telemetry/opentelemetry-erlang?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +Elli middleware for OpenTelemetry instrumentation. + +## Setup and Configuration + +``` erlang +{deps, [opentelemetry_elli]}. +``` + +While using the `elli_middleware` callback, place `oc_elli_middelware` as the first module to be called in the list of handlers: + +``` erlang +[{callback, elli_middleware}, + {callback_args, [{mods, [{otel_elli_middleware, []}, + {, []}]}]}] +``` + + + +OpenTelemetry's [HTTP Semantic Conventions](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#http-server) for a server details the attributes added to a Span automatically by using this middleware. One such attribute must be set in your `sys.config` under the `opentelemetry_elli` application like: + +``` erlang +{opentelemetry_elli, [{server_name, <<"my-http-server">>}]}. +``` + +It is strongly recommended to set this environment variable so the attribute can be included: + +> http.server_name has shown great value in practice, as bogus HTTP Host headers occur often in the wild. It is strongly recommended to set http.server_name to allow associating requests with some logical server entity. + +## Use + +### Including the Middleware and setting Span names + +The middleware takes care of extracting the parent Span from the requests +headers, both the [W3C](https://w3c.github.io/trace-context/) and [B3](https://github.com/openzipkin/b3-propagation) formats are supported. + +Because Elli has no router, there is no way to get a very descriptive Span +name automatically. See the OpenTelemetry docs [Semantic conventions for HTTP spans](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md#name) for +why you don't want to set the Span name to the raw path of the request. Thus, +the Span has the name `"HTTP {METHOD_NAME}"`. + +The macro `update_name` from `opentelemetry_api/include/otel_tracer.hrl` allows you +to update the name of the Span after it has started: + +``` erlang +handle(Req, Args) -> + handle(Req#req.path, Req, Args). + +handle([<<"hello">>, Who], Req, _Args) -> + ?update_name(<<"/hello/{who}">>), + {ok, [], <<"Hello ", Who/binary>>}. +``` + +Attributes set by the middleware can be found in the OpenTelemetry docs [Semantic +conventions for HTTP spans](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-http.md). + +Updating the Span name is not required. Another option is to create a child Span +with a more descriptive name, whose parent will be the Span named `"HTTP +{METHOD_NAME}"`. + +### Excluding Paths + +Not all paths are created equal. It is likely you don't want to Trace a health +check endpoint or `/metrics` if using Prometheus to scrape metrics. This +library offers two ways to exclude requests from potentially creating Spans by +filtering on the raw path in the URL. + +An application environment variable is read in on Elli start, so the following can +be added to `sys.config` to exclude the URLs `/health` and `/metrics`: + +``` erlang + +{opentelemetry_elli, [{excluded_urls, ["/health", "/metrics"]}]} +``` + +An OS environment variable, `OTEL_ELLI_EXCLUDED_URLS`, is also read and is a +comma separated list of paths. Note that the URLs in the variable are split with +a simple `string:split` call, so commas are not support in any URL. No escaping +or quoting are recognized, if a comma in a URL's path is required it should be +percent encoded as `%2C`, this is how the URL it is compared against will appear +in Elli anyway. + +The lists from both are merged. + +## Testing + +A Common Test suite which starts an Elli server and tests the exported Spans created by a test handle is found under `test/`. Run with: + +``` erlang +$ rebar3 ct +``` diff --git a/instrumentation/opentelemetry_elli/rebar.config b/instrumentation/opentelemetry_elli/rebar.config new file mode 100644 index 0000000..6a76e2d --- /dev/null +++ b/instrumentation/opentelemetry_elli/rebar.config @@ -0,0 +1,16 @@ +{erl_opts, [debug_info]}. + +{deps, [elli, + {opentelemetry_api, "~> 1.0"}]}. + +{project_plugins, [{rebar_covertool, "1.1.0"}]}. + +{profiles, [{test, [{erl_opts, [nowarn_export_all]}, + {deps, [{opentelemetry, "~> 1.0"}]}]}]}. + +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. +{covertool, [{coverdata_files, ["ct.coverdata"]}]}. + +{ct_opts, [{ct_hooks, [cth_surefire]}]}. diff --git a/instrumentation/opentelemetry_elli/rebar.lock b/instrumentation/opentelemetry_elli/rebar.lock new file mode 100644 index 0000000..6588f11 --- /dev/null +++ b/instrumentation/opentelemetry_elli/rebar.lock @@ -0,0 +1,11 @@ +{"1.2.0", +[{<<"elli">>,{pkg,<<"elli">>,<<"3.3.0">>},0}, + {<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.0.2">>},0}]}. +[ +{pkg_hash,[ + {<<"elli">>, <<"089218762A7FF3D20AE81C8E911BD0F73EE4EE0ED85454226D1FC6B4FFF3B4F6">>}, + {<<"opentelemetry_api">>, <<"91353EE40583B1D4F07D7B13ED62642ABFEC6AAA0D8A2114F07EDAFB2DF781C5">>}]}, +{pkg_hash_ext,[ + {<<"elli">>, <<"698B13B33D05661DB9FE7EFCBA41B84825A379CCE86E486CF6AFF9285BE0CCF8">>}, + {<<"opentelemetry_api">>, <<"2A8247F85C44216B883900067478D59955D11E58E5CFCA7C884CD4F203ACE3AC">>}]} +]. diff --git a/instrumentation/opentelemetry_elli/src/opentelemetry_elli.app.src b/instrumentation/opentelemetry_elli/src/opentelemetry_elli.app.src new file mode 100644 index 0000000..0850965 --- /dev/null +++ b/instrumentation/opentelemetry_elli/src/opentelemetry_elli.app.src @@ -0,0 +1,16 @@ +{application, opentelemetry_elli, + [{description, "Elli middleware for tracing and stats with OpenTelemetry"}, + {vsn, "git"}, + {registered, []}, + {applications, + [kernel, + stdlib, + opentelemetry_api, + elli + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache-2.0"]}, + {links, [{"GitHub", "https://github.com/opentelemetry-beam/opentelemetry_elli"}]} + ]}. diff --git a/instrumentation/opentelemetry_elli/src/otel_elli.erl b/instrumentation/opentelemetry_elli/src/otel_elli.erl new file mode 100644 index 0000000..1ecf00a --- /dev/null +++ b/instrumentation/opentelemetry_elli/src/otel_elli.erl @@ -0,0 +1,113 @@ +-module(otel_elli). + +-export([start_span/1]). + +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). +-include_lib("opentelemetry_api/include/opentelemetry.hrl"). +-include_lib("elli/include/elli.hrl"). + +start_span(Req) -> + Method = elli_request:method(Req), + RawPath = elli_request:raw_path(Req), + + %% TODO: fix in elli. it currently always returns `undefined' + %% Scheme = elli_request:scheme(Req), + + %% TODO: update elli to keep the whole url + %% Url = elli_request:url(Req), + + BinMethod = to_binary(Method), + SpanName = <<"HTTP ", BinMethod/binary>>, + UserAgent = elli_request:get_header(<<"User-Agent">>, Req, <<>>), + Host = case elli_request:host(Req) of + undefined -> + elli_request:get_header(<<"Host">>, Req, <<>>); + H -> + H + end, + %% TODO: attribute `http.route' should be an option to this function + {PeerIp, PeerPort} = peer_ip_and_port(Req), + {HostIp, HostPort} = host_ip_and_port(Req), + {ok, HostName} = inet:gethostname(), + + Flavor = flavor(Req), + + SpanCtx = ?start_span(SpanName, #{kind => ?SPAN_KIND_SERVER, + attributes => [{<<"http.target">>, RawPath}, + {<<"http.host">>, Host}, + %% {<<"http.url">>, Url}, + %% {<<"http.scheme">>, Scheme}, + {<<"http.flavor">>, Flavor}, + {<<"http.user_agent">>, UserAgent}, + {<<"http.method">>, BinMethod}, + {<<"net.peer.ip">>, PeerIp}, + {<<"net.peer.port">>, PeerPort}, + {<<"net.peer.name">>, Host}, + {<<"net.transport">>, <<"IP.TCP">>}, + {<<"net.host.ip">>, HostIp}, + {<<"net.host.port">>, HostPort}, + {<<"net.host.name">>, HostName} + | optional_attributes(Req)]}), + + ?set_current_span(SpanCtx), + ok. + +flavor(#req{version={1,1}}) -> + <<"1.1">>; +flavor(#req{version={1,0}}) -> + <<"1.0">>; +flavor(_) -> + <<>>. + +to_binary(Method) when is_atom(Method) -> + atom_to_binary(Method, utf8); +to_binary(Method) -> + Method. + +optional_attributes(Req) -> + lists:filtermap(fun({Attr, Fun}) -> + case Fun(Req) of + undefined -> + false; + Value -> + {true, {Attr, Value}} + end + end, [{<<"http.client_ip">>, fun client_ip/1}, + {<<"http.server_name">>, fun server_name/1}]). + +client_ip(Req) -> + case elli_request:get_header(<<"X-Forwarded-For">>, Req, undefined) of + undefined -> + undefined; + Ip -> + Ip + end. + +server_name(_) -> + application:get_env(opentelemetry_elli, server_name, undefined). + +peername({plain, Socket}) -> + inet:peername(Socket); +peername({ssl, Socket}) -> + ssl:peername(Socket). + +sockname({plain, Socket}) -> + inet:sockname(Socket); +sockname({ssl, Socket}) -> + ssl:sockname(Socket). + +peer_ip_and_port(#req{socket=Socket}) -> + case peername(Socket) of + {ok, {Address, Port}} -> + {list_to_binary(inet_parse:ntoa(Address)), Port}; + _ -> + undefined + end. + +host_ip_and_port(#req{socket=Socket}) -> + case sockname(Socket) of + {ok, {Address, Port}} -> + {list_to_binary(inet_parse:ntoa(Address)), Port}; + _ -> + undefined + end. diff --git a/instrumentation/opentelemetry_elli/src/otel_elli_middleware.erl b/instrumentation/opentelemetry_elli/src/otel_elli_middleware.erl new file mode 100644 index 0000000..f55d20e --- /dev/null +++ b/instrumentation/opentelemetry_elli/src/otel_elli_middleware.erl @@ -0,0 +1,190 @@ +%%%------------------------------------------------------------------------ +%% Copyright 2020, Tristan Sloughter +%% 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. +%% +%% @doc Elli middleware for tracing requests and recording stats. +%% @end +%%%------------------------------------------------------------------------- +-module(otel_elli_middleware). + +-export([preprocess/2, + handle/2, + handle_event/3]). + +-include_lib("elli/include/elli.hrl"). +-include_lib("opentelemetry_api/include/opentelemetry.hrl"). +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). + +-define(EXCLUDED_URLS, {?MODULE, excluded_urls}). + +preprocess(Req, _) -> + %% extract trace context from headers to be used as the parent + Headers = elli_request:headers(Req), + otel_propagator_text_map:extract(Headers), + case lists:member(elli_request:raw_path(Req), persistent_term:get(?EXCLUDED_URLS, [])) of + true -> + Req; + false -> + otel_elli:start_span(Req), + Req + end. + +handle(_Req, _Config) -> + ignore. + +handle_event(elli_startup, _Args, _Config) -> + ExcludedUrls = collect_excluded_urls(), + + %% TODO: This should really be per-server and not global across what could be multiple Elli + %% servers running within a node + persistent_term:put(?EXCLUDED_URLS, ExcludedUrls), + + ok; +handle_event(request_complete, Args, Config) -> + handle_full_response(request_complete, Args, Config); +handle_event(chunk_complete, Args, Config) -> + handle_full_response(chunk_complete, Args, Config); + +handle_event(request_timeout, _, _Config) -> + handle_exception(request_timeout); +handle_event(request_parse_error, [Reason], _Config) -> + handle_exception({request_parse_error, Reason}); +handle_event(client_closed, [RequestPart], _Config) -> + handle_exception({client_closed, RequestPart}); +handle_event(client_timeout, [RequestPart], _Config) -> + handle_exception({client_timeout, RequestPart}); +handle_event(bad_request, [Reason], _Config) -> + handle_exception({bad_request, Reason}); +handle_event(request_error, [_Req, Exception, Stacktrace], _Config) -> + handle_exception(error, Exception, Stacktrace); +handle_event(request_throw, [_Req, Exception, Stacktrace], _Config) -> + handle_exception(throw, Exception, Stacktrace); +handle_event(request_exit, [_Req, Exception, Stacktrace], _Config) -> + handle_exception(exit, Exception, Stacktrace); +handle_event(invalid_return, [_Req, _Unexpected], _Config) -> + %% TODO: should we include the `Unexpected' response in the attributes? + %% it could have sensitive data so should be configurable + handle_exception(invalid_return), + ok; +handle_event(_Event, _Args, _Config) -> + ok. + +%% + +handle_full_response(_Type, [_Req, Code, _Hs, _B, {_Timings, Sizes}], _Config) -> + maybe_set_req_body_size(Sizes), + maybe_set_resp_body_size(Sizes), + set_status(Code), + + %% end the span that the user might have started + %% if there is no started span this is a noop + ?end_span(), + + %% `end_span' does not change the active span context in the context + %% so here the whole context must be cleared or it'd be carried over + %% to the next request that runs in the same process. + otel_ctx:clear(), + + ok. + +set_status(Code) -> + Status = opentelemetry:status(http_to_otel_status(Code), <<>>), + ?set_status(Status), + ?set_attribute(<<"http.status">>, Code). + +%% Elli doesn't support any decompression so it would be up to the +%% user or their middleware/handover handler to set the attribute +%% `http.request_content_length_uncompressed' +maybe_set_req_body_size(Sizes) -> + case proplists:get_value(req_body, Sizes) of + undefined -> + ok; + ReqBodySize -> + ?set_attribute(<<"http.request_content_length">>, ReqBodySize) + end. + +%% Because Elli compression support is a middleware `elli_middleware_compress' +%% we are not able to set `http.request_content_length_uncompressed'. +%% So if the response body is compressed the `http.response_content_length' +%% will still be the uncompressed size. +maybe_set_resp_body_size(Sizes) -> + case proplists:get_value(chunks, Sizes) of + undefined -> + case proplists:get_value(file, Sizes) of + undefined -> + case proplists:get_value(resp_body, Sizes) of + undefined -> + ok; + RespBodySize -> + set_resp_body_size(RespBodySize) + end; + FileSize -> + set_resp_body_size(FileSize) + end; + ChunksSize -> + set_resp_body_size(ChunksSize) + end. + +set_resp_body_size(Size) -> + ?set_attribute(<<"http.response_content_length">>, Size). + +handle_exception(Reason) -> + ?set_attributes([{<<"error.message">>, format_reason(Reason)}]). + +handle_exception(Class, Reason, Stacktrace) -> + ?set_attributes([{<<"stacktrace">>, format_exception(Class, Reason, Stacktrace)}, + {<<"error.message">>, term_to_string(Reason)}]). + +format_reason(Reason) -> + term_to_string(Reason). + +-if(?OTP_RELEASE >= 24). +format_exception(Class, Reason, StackTrace) -> + erl_error:format_exception(Class, Reason, StackTrace). +-else. +format_exception(Class, Reason, StackTrace) -> + io_lib:format("~p:~p ~p", [Class, Reason, StackTrace]). +-endif. + +term_to_string(Term) -> + list_to_binary(io_lib:format("~p", [Term])). + +http_to_otel_status(Code) when Code >= 500 -> + ?OTEL_STATUS_ERROR; +http_to_otel_status(_) -> + ?OTEL_STATUS_UNSET. + +collect_excluded_urls() -> + %% support an app and os env var for setting paths to not trace + OSExcludePaths = case os:getenv("OTEL_ELLI_EXCLUDED_PATHS") of + false -> + []; + E -> + string:split(E, ",", all) + end, + + AppExcludedPaths = application:get_env(opentelemetry_elli, excluded_paths, []), + + lists:umerge(sort_paths(AppExcludedPaths), + sort_paths(OSExcludePaths)). + +sort_paths(Paths) -> + lists:usort(lists:filtermap(fun path_to_binary/1, Paths)). + +path_to_binary(Path) when is_list(Path) ; is_binary(Path) -> + case unicode:characters_to_binary(Path) of + {error, _, _} -> + false; + Binary -> + {true, Binary} + end. diff --git a/instrumentation/opentelemetry_elli/test/otel_elli_SUITE.erl b/instrumentation/opentelemetry_elli/test/otel_elli_SUITE.erl new file mode 100644 index 0000000..2dfe1d7 --- /dev/null +++ b/instrumentation/opentelemetry_elli/test/otel_elli_SUITE.erl @@ -0,0 +1,223 @@ +-module(otel_elli_SUITE). + +-compile(export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("opentelemetry_api/include/opentelemetry.hrl"). +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). +-include_lib("opentelemetry/include/otel_span.hrl"). +-include_lib("elli/include/elli.hrl"). + +all() -> + [{group, w3c}, {group, b3multi}, {group, b3}]. + +groups() -> + [{w3c, [shuffle], [successful_request_no_parent, successful_request, + error_response, excluded_paths]}, + {b3, [shuffle], [successful_request_no_parent, successful_request, + error_response, excluded_paths]}, + {b3multi, [shuffle], [successful_request_no_parent, successful_request, + error_response, excluded_paths]}]. + +init_per_suite(Config) -> + ok = application:load(opentelemetry_elli), + ok = application:load(opentelemetry), + + application:set_env(opentelemetry_elli, excluded_paths, ["/hello/exclude"]), + application:set_env(opentelemetry_elli, server_name, <<"my-test-elli-server">>), + application:set_env(opentelemetry, processors, [{otel_simple_processor, #{}}]), + + Config. + +end_per_suite(_Config) -> + application:unload(opentelemetry), + ok. + +init_per_group(Propagator, Config) -> + {ok, _} = application:ensure_all_started(opentelemetry), + + Propagators = case Propagator of + w3c -> + [baggage, trace_context]; + b3multi -> + [baggage, b3multi]; + b3 -> + [baggage, b3] + end, + CompositePropagator = otel_propagator_text_map_composite:create(Propagators), + opentelemetry:set_text_map_propagator(CompositePropagator), + + [{propagator, Propagator} | Config]. + +end_per_group(_, _Config)-> + application:stop(opentelemetry), + ok. + +init_per_testcase(_, Config) -> + elli:start_link([{name, {local, elli_test_server}}, + {port, 3000}, + {callback, elli_middleware}, + {callback_args, [{mods, [{otel_elli_middleware, []}, + {?MODULE, []}]}]}]), + + otel_simple_processor:set_exporter(otel_exporter_pid, self()), + Config. + +end_per_testcase(_, _Config) -> + elli:stop(elli_test_server), + ok. + +excluded_paths(_Config) -> + ?with_span(<<"remote-parent">>, #{}, + fun(_) -> + RequestHeaders = [{binary_to_list(K), binary_to_list(V)} + || {K, V} <- otel_propagator_text_map:inject([])], + {ok, {{_, 200, _}, _Headers, Body}} = + httpc:request(get, {"http://localhost:3000/hello/exclude", + RequestHeaders}, + [], []), + ?assertEqual("Hello exclude", Body) + end), + + receive + {span, #span{name=Name, + parent_span_id=Parent}} when Parent =/= undefined -> + ?assertEqual(<<"handler-child">>, Name), + + %% then receive the remote parent + receive + {span, #span{name=ParentName, + span_id=ParentSpanId, + parent_span_id=undefined}} when ParentSpanId =:= Parent -> + ?assertEqual(<<"remote-parent">>, ParentName), + + %% and guarantee that the mailbox is empty, meaning no elli_middleware span + receive + _ -> + ct:fail(mailbox_not_empty) + after + 0 -> + ok + end + after + 5000 -> + ct:fail(timeout) + end + after + 5000 -> + ct:fail(timeout) + end, + + ok. + +successful_request(_Config) -> + ?with_span(<<"remote-parent">>, #{}, + fun(_) -> + RequestHeaders = [{binary_to_list(K), binary_to_list(V)} + || {K, V} <- otel_propagator_text_map:inject([])], + {ok, {{_, 200, _}, _Headers, Body}} = + httpc:request(get, {"http://localhost:3000/hello/otel?a=b#fragment", + RequestHeaders}, + [], []), + ?assertEqual("Hello otel", Body) + end), + + receive + {span, #span{name = <<"/hello/{who}">>, + parent_span_id=Parent, + attributes=Attributes, + events=_TimeEvents}} when Parent =/= undefined -> + ?assertMatch(#{<<"http.server_name">> := <<"my-test-elli-server">>, + <<"http.target">> := <<"/hello/otel?a=b">>, + <<"http.host">> := <<"localhost:3000">>, + %% removed until updates to elli allow it + %% <<"http.url">> := <<"http://localhost:3000/hello/otel?a=b">>, + %% scheme is removed until fixed in elli + %% <<"http.scheme">> := <<"http">>, + <<"http.status">> := 200, + %% <<"http.user_agent">> := <<>>, + <<"http.method">> := <<"GET">>, + <<"net.host.port">> := 3000}, otel_attributes:map(Attributes)), + + %% then receive the remote parent + receive + {span, #span{name=ParentName, + span_id=ParentSpanId, + parent_span_id=undefined}} when ParentSpanId =:= Parent -> + ?assertEqual(<<"remote-parent">>, ParentName) + after + 5000 -> + ct:fail(timeout) + end + after + 5000 -> + ct:fail(timeout) + end, + + ok. + +successful_request_no_parent(_Config) -> + {ok, {{_, 200, _}, _Headers, Body}} = httpc:request("http://localhost:3000/hello/otel?a=b#fragment"), + ?assertEqual("Hello otel", Body), + + receive + {span, #span{name = <<"/hello/{who}">>, + parent_span_id=Parent, + attributes=Attributes, + events=_TimeEvents}} -> + ?assertEqual(undefined, Parent), + ?assertMatch(#{<<"http.server_name">> := <<"my-test-elli-server">>, + <<"http.target">> := <<"/hello/otel?a=b">>, + <<"http.host">> := <<"localhost:3000">>, + %% scheme is removed until fixed in elli + %% <<"http.scheme">> := <<"http">>, + <<"http.status">> := 200, + <<"http.user_agent">> := <<>>, + <<"http.method">> := <<"GET">>, + <<"net.host.port">> := 3000}, otel_attributes:map(Attributes)) + after + 5000 -> + ct:fail(timeout) + end, + ok. + +error_response(_Config) -> + {ok, {{_, 500, _}, _Headers, _Body}} = httpc:request("http://localhost:3000/error?a=b#fragment"), + + receive + {span, #span{name=Name, + parent_span_id=Parent, + attributes=Attributes}} -> + ?assertEqual(undefined, Parent), + ?assertEqual(<<"HTTP GET">>, Name), + ?assertMatch(#{<<"http.server_name">> := <<"my-test-elli-server">>, + <<"http.target">> := <<"/error?a=b">>, + <<"http.host">> := <<"localhost:3000">>, + <<"http.status">> := 500, + <<"http.user_agent">> := <<>>, + <<"error.message">> := <<"all_hell">>, + <<"http.method">> := <<"GET">>}, otel_attributes:map(Attributes)) + after + 5000 -> + ct:fail(timeout) + end, + ok. +%% + +handle(Req, Args) -> + handle(Req#req.path, Req, Args). + +handle([<<"hello">>, Who], _Req, _Args) -> + ?update_name(<<"/hello/{who}">>), + + ?with_span(<<"handler-child">>, #{}, + fun(_) -> + ok + end), + + {ok, [], <<"Hello ", Who/binary>>}; +handle([<<"error">>], _Req, _Args) -> + throw(all_hell). + +handle_event(_Event, _Data, _Args) -> + ok.