From 96bac1d0466d216edfa2c8360bdb1f0918194e04 Mon Sep 17 00:00:00 2001 From: Dan Schultzer <1254724+danschultzer@users.noreply.github.com> Date: Mon, 9 Dec 2024 19:38:39 -0800 Subject: [PATCH] OTel Resource Detector for AWS --- .github/hex-packages.json | 9 + .github/labeler.yml | 9 + .../opentelemetry-resource-detector-aws.yml | 6 + .github/workflows/erlang.yml | 32 ++ .github/workflows/publish-mix-hex-release.yml | 1 + .github/workflows/release-drafter.yml | 10 + .../LICENSE | 201 ++++++++++ .../README.md | 36 ++ .../rebar.config | 9 + .../rebar.lock | 16 + ...pentelemetry_resource_detector_aws.app.src | 17 + .../opentelemetry_resource_detector_aws.erl | 25 ++ .../src/otel_resource_aws_ecs.erl | 140 +++++++ ...ntelemetry_resource_detector_aws_SUITE.erl | 345 ++++++++++++++++++ 14 files changed, 856 insertions(+) create mode 100644 .github/release-drafter-templates/opentelemetry-resource-detector-aws.yml create mode 100644 detectors/opentelemetry_resource_detector_aws/LICENSE create mode 100644 detectors/opentelemetry_resource_detector_aws/README.md create mode 100644 detectors/opentelemetry_resource_detector_aws/rebar.config create mode 100644 detectors/opentelemetry_resource_detector_aws/rebar.lock create mode 100644 detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.app.src create mode 100644 detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.erl create mode 100644 detectors/opentelemetry_resource_detector_aws/src/otel_resource_aws_ecs.erl create mode 100644 detectors/opentelemetry_resource_detector_aws/test/opentelemetry_resource_detector_aws_SUITE.erl diff --git a/.github/hex-packages.json b/.github/hex-packages.json index 912f03ae..a93402cd 100644 --- a/.github/hex-packages.json +++ b/.github/hex-packages.json @@ -152,6 +152,15 @@ "language": "elixir", "authorizedUsers": ["bryannaegele", "tsloughter"] }, + "resource_detector_aws": { + "workingDirectory": "detectors/opentelemetry_resource_detector_aws", + "name": "Resource Detector for AWS", + "packageName": "opentelemetry_resource_detector_aws", + "tagPrefix": "opentelemetry-resource-detector-aws-v", + "buildTool": "rebar3", + "language": "erlang", + "authorizedUsers": ["danschultzer"] + }, "tesla": { "workingDirectory": "instrumentation/opentelemetry_tesla", "name": "Tesla Instrumentation", diff --git a/.github/labeler.yml b/.github/labeler.yml index 129a6752..61c59da2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -33,6 +33,9 @@ erlang: - utilities/**/*.erl - utilities/**/*.hrl - utilities/**/rebar.* + - detectors/**/*.erl + - detectors/**/*.hrl + - detectors/**/rebar.* instrumentation: - instrumentation/**/* @@ -46,6 +49,9 @@ examples: utilities: - utilities/**/* +detectors: + - detectors/**/* + scope-ci: - .github/workflows/** @@ -97,6 +103,9 @@ opentelemetry_redix: opentelemetry_req: - instrumentation/opentelemetry_req/**/* +opentelemetry_resource_detector_aws: + - detectors/opentelemetry_resource_detector_aws/**/* + opentelemetry_telemetry: - utilities/opentelemetry_telemetry/**/* diff --git a/.github/release-drafter-templates/opentelemetry-resource-detector-aws.yml b/.github/release-drafter-templates/opentelemetry-resource-detector-aws.yml new file mode 100644 index 00000000..e310c81a --- /dev/null +++ b/.github/release-drafter-templates/opentelemetry-resource-detector-aws.yml @@ -0,0 +1,6 @@ +_extends: opentelemetry-erlang-contrib:.github/release-drafter.yml +name-template: 'Opentelemetry Resource Detector for AWS - v$RESOLVED_VERSION' +tag-template: 'opentelemetry-resource-detector-aws-v$RESOLVED_VERSION' +tag-prefix: opentelemetry-resource-detector-aws-v +include-paths: + - detectors/opentelemetry_resource_detector_aws/ diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml index 2b4d235d..aeb8e832 100644 --- a/.github/workflows/erlang.yml +++ b/.github/workflows/erlang.yml @@ -155,6 +155,38 @@ jobs: - name: Test run: rebar3 ct + opentelemetry-reource-detector-aws: + needs: [test-matrix] + if: (contains(github.event.pull_request.labels.*.name, 'erlang') && contains(github.event.pull_request.labels.*.name, 'opentelemetry_resource_detector_aws')) + env: + app: "opentelemetry_resource_detector_aws" + defaults: + run: + working-directory: detectors/${{ env.app }} + runs-on: ${{ matrix.os }} + name: Opentelemetry Resource Detector for AWS test on OTP ${{ matrix.otp_version }} with Rebar3 ${{ matrix.rebar3_version }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.test-matrix.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + version-type: strict + otp-version: ${{ matrix.otp_version }} + rebar3-version: ${{ matrix.rebar3_version }} + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/_build + key: ${{ runner.os }}-build-${{ matrix.otp_version }}-${{ matrix.rebar3_version }}-v3-${{ hashFiles('**/rebar.lock') }} + - name: Fetch deps + if: steps.deps-cache.outputs.cache-hit != 'true' + run: rebar3 get-deps + - name: Test + run: rebar3 ct + opentelemetry-telemetry: needs: [test-matrix] if: (contains(github.event.pull_request.labels.*.name, 'erlang') && contains(github.event.pull_request.labels.*.name, 'opentelemetry_telemetry')) diff --git a/.github/workflows/publish-mix-hex-release.yml b/.github/workflows/publish-mix-hex-release.yml index 50607a03..cdfeda82 100644 --- a/.github/workflows/publish-mix-hex-release.yml +++ b/.github/workflows/publish-mix-hex-release.yml @@ -25,6 +25,7 @@ on: - "process_propagator" - "redix" - "req" + - "resource_detector_aws" - "tesla" - "xandra" required: true diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 171baaa8..3eb541c9 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -157,6 +157,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + opentelemetry-resource-detector-aws: + name: '[opentelemetry-resource-detector-aws] Draft release' + runs-on: ubuntu-24.04 + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter-templates/opentelemetry-resource-detector-aws.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + opentelemetry-req-release: name: '[opentelemetry-req-release] Draft release' runs-on: ubuntu-24.04 diff --git a/detectors/opentelemetry_resource_detector_aws/LICENSE b/detectors/opentelemetry_resource_detector_aws/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/detectors/opentelemetry_resource_detector_aws/README.md b/detectors/opentelemetry_resource_detector_aws/README.md new file mode 100644 index 00000000..56256656 --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/README.md @@ -0,0 +1,36 @@ +# OpentelemetryResourceDetectorAWS + +Implements a Resource Detector for AWS. + +## Installation + +The package can be installed by adding `opentelemetry_resource_detector_aws` to your list of +dependencies in `mix.exs` for elixir and `rebar.config` for erlang : + +```erlang +{deps, [opentelemetry_resource_detector_aws]}. +``` + +```elixir +def deps do + [ + {:opentelemetry_resource_detector_aws, "~> 0.1"} + ] +end +``` + +## Usage + +Configure the OpenTelemetry to use the Resource Detector for AWS: + +```elixir +config :opentelemetry, + resource_detectors: [:otel_resource_aws_ecs] +``` + +```erlang +[ + {opentelemetry, + [{resource_detectors, otel_resource_aws_ecs}]} +]. +``` diff --git a/detectors/opentelemetry_resource_detector_aws/rebar.config b/detectors/opentelemetry_resource_detector_aws/rebar.config new file mode 100644 index 00000000..c1ff1cac --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/rebar.config @@ -0,0 +1,9 @@ +{erl_opts, [debug_info]}. + +{deps, [ + {opentelemetry, "~> 1.5"}, + {opentelemetry_semantic_conventions, "~> 1.27"} +]}. + +{profiles, [{test, [{erl_opts, [nowarn_export_all]}, + {deps, [{meck, ">= 0.0.0"}]}]}]}. diff --git a/detectors/opentelemetry_resource_detector_aws/rebar.lock b/detectors/opentelemetry_resource_detector_aws/rebar.lock new file mode 100644 index 00000000..7f1652de --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/rebar.lock @@ -0,0 +1,16 @@ +{"1.2.0", +[{<<"opentelemetry">>,{pkg,<<"opentelemetry">>,<<"1.5.0">>},0}, + {<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.4.0">>},1}, + {<<"opentelemetry_semantic_conventions">>, + {pkg,<<"opentelemetry_semantic_conventions">>,<<"1.27.0">>}, + 0}]}. +[ +{pkg_hash,[ + {<<"opentelemetry">>, <<"7DDA6551EDFC3050EA4B0B40C0D2570423D6372B97E9C60793263EF62C53C3C2">>}, + {<<"opentelemetry_api">>, <<"63CA1742F92F00059298F478048DFB826F4B20D49534493D6919A0DB39B6DB04">>}, + {<<"opentelemetry_semantic_conventions">>, <<"ACD0194A94A1E57D63DA982EE9F4A9F88834AE0B31B0BD850815FE9BE4BBB45F">>}]}, +{pkg_hash_ext,[ + {<<"opentelemetry">>, <<"CDF4F51D17B592FC592B9A75F86A6F808C23044BA7CF7B9534DEBBCC5C23B0EE">>}, + {<<"opentelemetry_api">>, <<"3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58">>}, + {<<"opentelemetry_semantic_conventions">>, <<"9681CCAA24FD3D810B4461581717661FD85FF7019B082C2DFF89C7D5B1FC2864">>}]} +]. diff --git a/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.app.src b/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.app.src new file mode 100644 index 00000000..c5d71173 --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.app.src @@ -0,0 +1,17 @@ +{application, opentelemetry_resource_detector_aws, [ + {description, "OpenTelemetry resource detector for AWS"}, + {vsn, "0.0.1"}, + {registered, []}, + {applications, + [kernel, + stdlib, + inets, + opentelemetry, + opentelemetry_semantic_conventions + ]}, + {env, []}, + {modules, []}, + + {licenses, ["Apache-2.0"]}, + {links, [{"GitHub", "https://github.com/open-telemetry/opentelemetry-erlang-contrib/tree/main/detectors/opentelemetry_resource_detector_aws"}]} +]}. diff --git a/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.erl b/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.erl new file mode 100644 index 00000000..52f9f979 --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/src/opentelemetry_resource_detector_aws.erl @@ -0,0 +1,25 @@ +-module(opentelemetry_resource_detector_aws). + +-export([json_request/2]). + +json_request(Method, URL) -> + case httpc:request(Method, {URL, [{"User-Agent", user_agent()}]}, [], []) of + {ok, {{_, 200, _}, _, Body}} -> + case json_decode(Body) of + {ok, Data} -> {ok, Data}; + {error, Error} -> {error, {invalid_json, Error}} + end; + {ok, {{_, Code, _}, _, _}} -> {error, {invalid_status_code, Code}}; + {error, Error} -> {error, Error} + end. + +json_decode(String) -> + try json:decode(list_to_binary(String)) of + Data -> {ok, Data} + catch + _:Error -> {error, Error} + end. + +user_agent() -> + {ok, Vsn} = application:get_key(opentelemetry_resource_detector_aws, vsn), + lists:flatten(io_lib:format("OTel-Resource-Detector-AWS-erlang/~s", [Vsn])). diff --git a/detectors/opentelemetry_resource_detector_aws/src/otel_resource_aws_ecs.erl b/detectors/opentelemetry_resource_detector_aws/src/otel_resource_aws_ecs.erl new file mode 100644 index 00000000..f0b0f2ac --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/src/otel_resource_aws_ecs.erl @@ -0,0 +1,140 @@ +-module(otel_resource_aws_ecs). + +-behavior(otel_resource_detector). + +-export([get_resource/1]). + +-include_lib("kernel/include/logger.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/cloud_attributes.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/container_attributes.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/aws_attributes.hrl"). + +-define(OS_ENV, "ECS_CONTAINER_METADATA_URI_V4"). +-define(DEFAULT_CGROUP_PATH, "/proc/self/cgroup"). + +%% @private +-spec get_resource(list()) -> otel_resource:t() | no_return(). +get_resource(Config) -> + otel_resource:create(fetch(Config)). + +fetch(Config) -> + case os:getenv(?OS_ENV) of + false -> + erlang:error('Not running in ECS environment, ECS_CONTAINER_METADATA_URI_V4 not set.'); + _MetadataV4URL -> + [ + {?CLOUD_PROVIDER, atom_to_list(?CLOUD_PROVIDER_VALUES_AWS)}, + {?CLOUD_PLATFORM, atom_to_list(?CLOUD_PLATFORM_VALUES_AWS_ECS)} | + aws_ecs_attributes(Config) + ] + end. + +aws_ecs_attributes(Config) -> + {ok, Hostname} = inet:gethostname(), + + ContainerIDAttributes = + case fetch_container_id_attributes(Config) of + {ok, ContainerIDAttributesResult} -> + ContainerIDAttributesResult; + {error, ContainerIDAttributesError} -> + ?LOG_ERROR("Failed to fetch Container ID attributes: ~s", [ContainerIDAttributesError]), + [] + end, + + MetadataV4Attributes = + case fetch_metadata_v4_attributes() of + {ok, MetadataV4AttributesResult} -> + MetadataV4AttributesResult; + {error, MetadataV4AttributesError} -> + ?LOG_ERROR("Failed to fetch metadata attributes: ~s", [MetadataV4AttributesError]), + [] + end, + + [{?CONTAINER_NAME, Hostname}] ++ ContainerIDAttributes ++ MetadataV4Attributes. + +fetch_container_id_attributes(Config) -> + Path = proplists:get_value(cgroup_path, Config, ?DEFAULT_CGROUP_PATH), + + case file:read_file(Path) of + {ok, Data} -> + Lines = binary:split(Data, <<"\n">>, [global]), + ContainerId = lists:foldl(fun + (Line, none) -> + leength, + if byte_size(Line) > 64 -> + {ok, binary:part(Line, byte_size(Line) - 64, 64)}; + true -> + none + end; + + (_Line, Acc) -> + Acc + end, none, Lines), + case ContainerId of + none -> {error, "Container ID not found in " ++ Path}; + {ok, ContainerIdValue} -> {ok, [{?CONTAINER_ID, ContainerIdValue}]} + end; + {error, Error} -> + {error, "Failed to read " ++ Path ++ ": " ++ io_lib:format("~p", [Error])} + end. + +fetch_metadata_v4_attributes() -> + ContainerURL = os:getenv(?OS_ENV), + TaskURL = ContainerURL ++ "/task", + case opentelemetry_resource_detector_aws:json_request(get, ContainerURL) of + {ok, ContainerMetadata} -> + case opentelemetry_resource_detector_aws:json_request(get, TaskURL) of + {ok, TaskMetadata} -> + ContainerARN = maps:get(<<"ContainerARN">>, ContainerMetadata), + [_, _, _, Region, AccountId | _Resource] = binary:split(ContainerARN, <<":">>, [global]), + TaskARN = maps:get(<<"TaskARN">>, TaskMetadata), + ClusterARNOrShortName = maps:get(<<"Cluster">>, TaskMetadata), + ClusterARN = + case ClusterARNOrShortName of + <<"arn:", _Rest/binary>> -> ClusterARNOrShortName; + <<_Any/binary>> -> <<"arn:aws:ecs:", Region/binary, ":", AccountId/binary, ":cluster/", ClusterARNOrShortName/binary>> + end, + LaunchType = binary:list_to_bin(string:to_lower(binary:bin_to_list(maps:get(<<"LaunchType">>, TaskMetadata)))), + + Attributes = + [ + {?AWS_ECS_CONTAINER_ARN, ContainerARN}, + {?AWS_ECS_CLUSTER_ARN, ClusterARN}, + {?AWS_ECS_LAUNCHTYPE, LaunchType}, + {?AWS_ECS_TASK_ARN, TaskARN}, + {?AWS_ECS_TASK_FAMILY, maps:get(<<"Family">>, TaskMetadata)}, + {?AWS_ECS_TASK_REVISION, maps:get(<<"Revision">>, TaskMetadata)}, + {?CLOUD_ACCOUNT_ID, AccountId}, + {?CLOUD_REGION, Region}, + {?CLOUD_RESOURCE_ID, ContainerARN} + ] ++ case maps:find(<<"AvailabilityZone">>, TaskMetadata) of + {ok, AZ} -> [{?CLOUD_AVAILABILITY_ZONE, AZ}]; + error -> [] + end ++ case {maps:get(<<"LogDriver">>, ContainerMetadata), maps:is_key(<<"LogOptions">>, ContainerMetadata)} of + {<<"awslogs">>, true} -> + LogOptions = maps:get(<<"LogOptions">>, ContainerMetadata), + Region = maps:get(<<"awslogs-region">>, LogOptions, Region), + GroupName = maps:get(<<"awslogs-group">>, LogOptions), + GroupARN = <<"arn:aws:logs:", Region/binary, ":", AccountId/binary, ":log-group:", GroupName/binary>>, + StreamName = maps:get(<<"awslogs-stream">>, LogOptions), + StreamARN = <>, + + [ + {?AWS_LOG_GROUP_NAMES, [GroupName]}, + {?AWS_LOG_GROUP_ARNS, [GroupARN]}, + {?AWS_LOG_STREAM_NAMES, [StreamName]}, + {?AWS_LOG_STREAM_ARNS, [StreamARN]} + ]; + {_, _} -> + [] + end, + + {ok, Attributes}; + + {error, Error} -> + {error, Error} + end; + + {error, Error} -> + {error, Error} + end. diff --git a/detectors/opentelemetry_resource_detector_aws/test/opentelemetry_resource_detector_aws_SUITE.erl b/detectors/opentelemetry_resource_detector_aws/test/opentelemetry_resource_detector_aws_SUITE.erl new file mode 100644 index 00000000..ff28b9b7 --- /dev/null +++ b/detectors/opentelemetry_resource_detector_aws/test/opentelemetry_resource_detector_aws_SUITE.erl @@ -0,0 +1,345 @@ +-module(opentelemetry_resource_detector_aws_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/cloud_attributes.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/container_attributes.hrl"). +-include_lib("opentelemetry_semantic_conventions/include/incubating/attributes/aws_attributes.hrl"). + +-define(CGROUP_DATA, <<"12:rdma:/ +11:perf_event:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +10:freezer:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +9:memory:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +8:cpuset:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +7:devices:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +6:net_cls,net_prio:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +5:hugetlb:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +4:pids:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +3:cpu,cpuacct:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +2:blkio:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +1:name=systemd:/docker/a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81 +0::/system.slice/containerd.service">>). + +-define(CONTAINER_METADATA, "{ + \"DockerId\": \"ea32192c8553fbff06c9340478a2ff089b2bb5646fb718b4ee206641c9086d66\", + \"Name\": \"curl\", + \"DockerName\": \"ecs-curltest-24-curl-cca48e8dcadd97805600\", + \"Image\": \"111122223333.dkr.ecr.us-west-2.amazonaws.com/curltest:latest\", + \"ImageID\": \"sha256:d691691e9652791a60114e67b365688d20d19940dde7c4736ea30e660d8d3553\", + \"Labels\": { + \"com.amazonaws.ecs.cluster\": \"default\", + \"com.amazonaws.ecs.container-name\": \"curl\", + \"com.amazonaws.ecs.task-arn\": \"arn:aws:ecs:us-west-2:111122223333:task/default/8f03e41243824aea923aca126495f665\", + \"com.amazonaws.ecs.task-definition-family\": \"curltest\", + \"com.amazonaws.ecs.task-definition-version\": \"24\" + }, + \"DesiredStatus\": \"RUNNING\", + \"KnownStatus\": \"RUNNING\", + \"Limits\": { + \"CPU\": 10, + \"Memory\": 128 + }, + \"CreatedAt\": \"2020-10-02T00:15:07.620912337Z\", + \"StartedAt\": \"2020-10-02T00:15:08.062559351Z\", + \"Type\": \"NORMAL\", + \"LogDriver\": \"awslogs\", + \"LogOptions\": { + \"awslogs-create-group\": \"true\", + \"awslogs-group\": \"/ecs/metadata\", + \"awslogs-region\": \"us-west-2\", + \"awslogs-stream\": \"ecs/curl/8f03e41243824aea923aca126495f665\" + }, + \"ContainerARN\": \"arn:aws:ecs:us-west-2:111122223333:container/0206b271-b33f-47ab-86c6-a0ba208a70a9\", + \"Networks\": [ + { + \"NetworkMode\": \"awsvpc\", + \"IPv4Addresses\": [ + \"10.0.2.100\" + ], + \"AttachmentIndex\": 0, + \"MACAddress\": \"0e:9e:32:c7:48:85\", + \"IPv4SubnetCIDRBlock\": \"10.0.2.0/24\", + \"PrivateDNSName\": \"ip-10-0-2-100.us-west-2.compute.internal\", + \"SubnetGatewayIpv4Address\": \"10.0.2.1/24\" + } + ] +}"). + +-define(TASK_METADATA, "{ + \"Cluster\": \"default\", + \"TaskARN\": \"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c\", + \"Family\": \"curltest\", + \"ServiceName\": \"MyService\", + \"Revision\": \"26\", + \"DesiredStatus\": \"RUNNING\", + \"KnownStatus\": \"RUNNING\", + \"PullStartedAt\": \"2020-10-02T00:43:06.202617438Z\", + \"PullStoppedAt\": \"2020-10-02T00:43:06.31288465Z\", + \"AvailabilityZone\": \"us-west-2d\", + \"VPCID\": \"vpc-1234567890abcdef0\", + \"LaunchType\": \"EC2\", + \"Containers\": [ + { + \"DockerId\": \"598cba581fe3f939459eaba1e071d5c93bb2c49b7d1ba7db6bb19deeb70d8e38\", + \"Name\": \"~internal~ecs~pause\", + \"DockerName\": \"ecs-curltest-26-internalecspause-e292d586b6f9dade4a00\", + \"Image\": \"amazon/amazon-ecs-pause:0.1.0\", + \"ImageID\": \"\", + \"Labels\": { + \"com.amazonaws.ecs.cluster\": \"default\", + \"com.amazonaws.ecs.container-name\": \"~internal~ecs~pause\", + \"com.amazonaws.ecs.task-arn\": \"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c\", + \"com.amazonaws.ecs.task-definition-family\": \"curltest\", + \"com.amazonaws.ecs.task-definition-version\": \"26\" + }, + \"DesiredStatus\": \"RESOURCES_PROVISIONED\", + \"KnownStatus\": \"RESOURCES_PROVISIONED\", + \"Limits\": { + \"CPU\": 0, + \"Memory\": 0 + }, + \"CreatedAt\": \"2020-10-02T00:43:05.602352471Z\", + \"StartedAt\": \"2020-10-02T00:43:06.076707576Z\", + \"Type\": \"CNI_PAUSE\", + \"Networks\": [ + { + \"NetworkMode\": \"awsvpc\", + \"IPv4Addresses\": [ + \"10.0.2.61\" + ], + \"AttachmentIndex\": 0, + \"MACAddress\": \"0e:10:e2:01:bd:91\", + \"IPv4SubnetCIDRBlock\": \"10.0.2.0/24\", + \"PrivateDNSName\": \"ip-10-0-2-61.us-west-2.compute.internal\", + \"SubnetGatewayIpv4Address\": \"10.0.2.1/24\" + } + ] + }, + { + \"DockerId\": \"ee08638adaaf009d78c248913f629e38299471d45fe7dc944d1039077e3424ca\", + \"Name\": \"curl\", + \"DockerName\": \"ecs-curltest-26-curl-a0e7dba5aca6d8cb2e00\", + \"Image\": \"111122223333.dkr.ecr.us-west-2.amazonaws.com/curltest:latest\", + \"ImageID\": \"sha256:d691691e9652791a60114e67b365688d20d19940dde7c4736ea30e660d8d3553\", + \"Labels\": { + \"com.amazonaws.ecs.cluster\": \"default\", + \"com.amazonaws.ecs.container-name\": \"curl\", + \"com.amazonaws.ecs.task-arn\": \"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c\", + \"com.amazonaws.ecs.task-definition-family\": \"curltest\", + \"com.amazonaws.ecs.task-definition-version\": \"26\" + }, + \"DesiredStatus\": \"RUNNING\", + \"KnownStatus\": \"RUNNING\", + \"Limits\": { + \"CPU\": 10, + \"Memory\": 128 + }, + \"CreatedAt\": \"2020-10-02T00:43:06.326590752Z\", + \"StartedAt\": \"2020-10-02T00:43:06.767535449Z\", + \"Type\": \"NORMAL\", + \"LogDriver\": \"awslogs\", + \"LogOptions\": { + \"awslogs-create-group\": \"true\", + \"awslogs-group\": \"/ecs/metadata\", + \"awslogs-region\": \"us-west-2\", + \"awslogs-stream\": \"ecs/curl/158d1c8083dd49d6b527399fd6414f5c\" + }, + \"ContainerARN\": \"arn:aws:ecs:us-west-2:111122223333:container/abb51bdd-11b4-467f-8f6c-adcfe1fe059d\", + \"Networks\": [ + { + \"NetworkMode\": \"awsvpc\", + \"IPv4Addresses\": [ + \"10.0.2.61\" + ], + \"AttachmentIndex\": 0, + \"MACAddress\": \"0e:10:e2:01:bd:91\", + \"IPv4SubnetCIDRBlock\": \"10.0.2.0/24\", + \"PrivateDNSName\": \"ip-10-0-2-61.us-west-2.compute.internal\", + \"SubnetGatewayIpv4Address\": \"10.0.2.1/24\" + } + ] + } + ] +}"). + +all() -> + [ + json_request_with_httpc_error, + json_request_when_httpc_doesnt_have_200_status, + json_request_with_invalid_json, + json_request_with_valid_json, + ecs_get_resource_when_env_not_set, + ecs_get_resource_with_invalid_cgroup_file, + ecs_get_resource_with_invalid_container_metadata, + ecs_get_resource_with_invalid_task_metadata, + ecs_get_resource_should_return_ecs_metadata + ]. + +init_per_suite(Config) -> + application:ensure_all_started(opentelemetry_resource_detector_aws), + Config. + +end_per_suite(_Config) -> + application:stop(opentelemetry_resource_detector_aws), + ok. + +ecs_get_resource_when_env_not_set(_Config) -> + try otel_resource_aws_ecs:get_resource([]) of + _ -> ct:fail(failed_to_catch_exception) + catch + _Class:Reason -> ?assertEqual('Not running in ECS environment, ECS_CONTAINER_METADATA_URI_V4 not set.', Reason) + end. + +json_request_with_httpc_error(_Config) -> + meck:new(httpc), + meck:expect(httpc, request, fun(get, _, _, _) -> {error, invalid} end), + + ?assertEqual({error, invalid}, opentelemetry_resource_detector_aws:json_request(get, "http://localhost")), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +json_request_when_httpc_doesnt_have_200_status(_Config) -> + meck:new(httpc), + meck:expect(httpc, request, fun(get, _, _, _) -> {ok, {{"1.1", 401, ""}, [], <<>>}} end), + + ?assertEqual({error, {invalid_status_code, 401}}, opentelemetry_resource_detector_aws:json_request(get, "http://localhost")), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +json_request_with_invalid_json(_Config) -> + meck:new(httpc), + meck:expect(httpc, request, fun(get, _, _, _) -> {ok, {{"1.1", 200, ""}, [], "-"}} end), + + ?assertEqual({error, {invalid_json, unexpected_end}}, opentelemetry_resource_detector_aws:json_request(get, "http://localhost")), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +json_request_with_valid_json(_Config) -> + meck:new(httpc), + meck:expect(httpc, request, fun(get, {_URL, Headers}, _, _) -> + {_, UserAgent} = lists:keyfind("User-Agent", 1, Headers), + {ok, ExporterVsn} = application:get_key(opentelemetry_resource_detector_aws, vsn), + ExpectedUserAgent = lists:flatten(io_lib:format("OTel-Resource-Detector-AWS-erlang/~s", [ExporterVsn])), + + ?assertEqual(ExpectedUserAgent, UserAgent), + + {ok, {{"1.1", 200, ""}, [], "{}"}} + end), + + ?assertEqual({ok, #{}}, opentelemetry_resource_detector_aws:json_request(get, "http://localhost")), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +ecs_get_resource_with_invalid_cgroup_file(_Config) -> + os:putenv("ECS_CONTAINER_METADATA_URI_V4", "http://localhost"), + + meck:new(httpc), + meck:expect(httpc, request, fun + (get, {"http://localhost", _Headers}, _, _) -> {ok, {{"1.1", 200, ""}, [], ?CONTAINER_METADATA}}; + (get, {"http://localhost/task", _Headers}, _, _) -> {ok, {{"1.1", 200, ""}, [], ?TASK_METADATA}} + end), + + Resource = otel_resource_aws_ecs:get_resource([{cgroup_path, "/path/to/invalid/file"}]), + + ?assertNot(maps:is_key(?CONTAINER_ID, otel_attributes:map(otel_resource:attributes(Resource)))), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +ecs_get_resource_with_invalid_container_metadata(_Config) -> + os:putenv("ECS_CONTAINER_METADATA_URI_V4", "http://localhost"), + Path = create_dummy_cgroup_file(), + + meck:new(httpc), + meck:expect(httpc, request, fun(get, {_URL, _Headers}, _, _) -> {ok, {{"1.1", 500, ""}, [], <<>>}} end), + + Resource = otel_resource_aws_ecs:get_resource([{cgroup_path, Path}]), + + ?assertNot(maps:is_key(?AWS_ECS_CONTAINER_ARN, otel_attributes:map(otel_resource:attributes(Resource)))), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +ecs_get_resource_with_invalid_task_metadata(_Config) -> + os:putenv("ECS_CONTAINER_METADATA_URI_V4", "http://localhost"), + Path = create_dummy_cgroup_file(), + + meck:new(httpc), + meck:expect(httpc, request, fun + (get, {"http://localhost", _Headers}, _, _) -> {ok, {{"1.1", 200, ""}, [], ?CONTAINER_METADATA}}; + (get, {"http://localhost/task", _Headers}, _, _) -> {ok, {{"1.1", 500, ""}, [], <<>>}} + end), + + Resource = otel_resource_aws_ecs:get_resource([{cgroup_path, Path}]), + + ?assertNot(maps:is_key(?AWS_ECS_CONTAINER_ARN, otel_attributes:map(otel_resource:attributes(Resource)))), + + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +ecs_get_resource_should_return_ecs_metadata(_Config) -> + os:putenv("ECS_CONTAINER_METADATA_URI_V4", "http://localhost"), + Path = create_dummy_cgroup_file(), + {ok, Hostname} = inet:gethostname(), + + meck:new(httpc), + meck:expect(httpc, request, fun + (get, {"http://localhost", _Headers}, _, _) -> {ok, {{"1.1", 200, ""}, [], ?CONTAINER_METADATA}}; + (get, {"http://localhost/task", _Headers}, _, _) -> {ok, {{"1.1", 200, ""}, [], ?TASK_METADATA}} + end), + Resource = otel_resource_aws_ecs:get_resource([{cgroup_path, Path}]), + + ?assertEqual( + #{ + ?CLOUD_PROVIDER => <<"aws">>, + ?CLOUD_PLATFORM => <<"aws_ecs">>, + ?CONTAINER_NAME => binary:list_to_bin(Hostname), + ?CONTAINER_ID => <<"a2ffe0e97ac22657a2a023ad628e9df837c38a03b1ebc904d3f6d644eb1a1a81">>, + ?AWS_ECS_CONTAINER_ARN => <<"arn:aws:ecs:us-west-2:111122223333:container/0206b271-b33f-47ab-86c6-a0ba208a70a9">>, + ?AWS_ECS_CLUSTER_ARN => <<"arn:aws:ecs:us-west-2:111122223333:cluster/default">>, + ?AWS_ECS_LAUNCHTYPE => <<"ec2">>, + ?AWS_ECS_TASK_ARN => <<"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c">>, + ?AWS_ECS_TASK_FAMILY => <<"curltest">>, + ?AWS_ECS_TASK_REVISION => <<"26">>, + ?CLOUD_ACCOUNT_ID => <<"111122223333">>, + ?CLOUD_REGION => <<"us-west-2">>, + ?CLOUD_RESOURCE_ID => <<"arn:aws:ecs:us-west-2:111122223333:container/0206b271-b33f-47ab-86c6-a0ba208a70a9">>, + ?CLOUD_AVAILABILITY_ZONE => <<"us-west-2d">>, + ?AWS_LOG_GROUP_NAMES => <<"/ecs/metadata">>, + ?AWS_LOG_GROUP_ARNS => <<"arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata">>, + ?AWS_LOG_STREAM_NAMES => <<"ecs/curl/8f03e41243824aea923aca126495f665">>, + ?AWS_LOG_STREAM_ARNS => <<"arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:log-stream:ecs/curl/8f03e41243824aea923aca126495f665">> + }, + otel_attributes:map(otel_resource:attributes(Resource))), + ?assert(meck:validate(httpc)), + meck:unload(httpc). + +create_dummy_cgroup_file() -> + {ok, Path} = mktemp("opentelemetry_resource_detector_aws_SUITE"), + file:write_file(Path, ?CGROUP_DATA), + Path. + +mktemp(Prefix) -> + Rand = integer_to_list(binary:decode_unsigned(crypto:strong_rand_bytes(8)), 36), + TempDir = filename:basedir(user_cache, Prefix), + TempFile = filename:join(TempDir, Rand), + []= os:cmd("mkdir -p " ++ "\"" ++ TempDir ++ "\""), + {ok, _} = file:list_dir(TempDir), + Result = file:write_file(TempFile, <<>>), + case {Result} of + {ok} -> {ok, TempFile}; + {Error} -> Error + end. + +http_body(URL) -> + case URL of + "metadata_uri_v4" -> ?CONTAINER_METADATA; + "metadata_uri_v4/task" -> ?TASK_METADATA + end.