├── .ci └── make.sh ├── .github ├── check-license-headers.sh ├── license-header.txt └── workflows │ ├── license.yml │ ├── test-integration.yml │ └── test-unit.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── NOTICE ├── README.md ├── elastictransport ├── connection.go ├── connection_benchmark_test.go ├── connection_integration_test.go ├── connection_internal_test.go ├── discovery.go ├── discovery_internal_test.go ├── doc.go ├── elastictransport.go ├── elastictransport_benchmark_test.go ├── elastictransport_integration_multinode_test.go ├── elastictransport_integration_test.go ├── elastictransport_internal_test.go ├── gzip.go ├── instrumentation.go ├── instrumentation_test.go ├── logger.go ├── logger_benchmark_test.go ├── logger_internal_test.go ├── metrics.go ├── metrics_internal_test.go ├── testdata │ ├── cert.pem │ ├── key.pem │ └── nodes.info.json └── version │ └── version.go ├── go.mod └── go.sum /.ci/make.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # ------------------------------------------------------- # 4 | # 5 | # Skeleton for common build entry script for all elastic 6 | # clients. Needs to be adapted to individual client usage. 7 | # 8 | # Must be called: ./.ci/make.sh 9 | # 10 | # Version: 1.1.0 11 | # 12 | # Targets: 13 | # --------------------------- 14 | # assemble : build client artefacts with version 15 | # bump : bump client internals to version 16 | # codegen : generate endpoints 17 | # docsgen : generate documentation 18 | # examplegen : generate the doc examples 19 | # clean : clean workspace 20 | # 21 | # ------------------------------------------------------- # 22 | 23 | # ------------------------------------------------------- # 24 | # Bootstrap 25 | # ------------------------------------------------------- # 26 | 27 | script_path=$(dirname "$(realpath -s "$0")") 28 | repo=$(realpath "$script_path/../") 29 | 30 | # shellcheck disable=SC1090 31 | CMD=$1 32 | TASK=$1 33 | TASK_ARGS=() 34 | VERSION=$2 35 | STACK_VERSION=$VERSION 36 | set -euo pipefail 37 | 38 | product="elastic/client-skel" 39 | output_folder=".ci/output" 40 | codegen_folder=".ci/output" 41 | OUTPUT_DIR="$repo/${output_folder}" 42 | REPO_BINDING="${OUTPUT_DIR}:/sln/${output_folder}" 43 | mkdir -p "$OUTPUT_DIR" 44 | 45 | echo -e "\033[34;1mINFO:\033[0m PRODUCT ${product}\033[0m" 46 | echo -e "\033[34;1mINFO:\033[0m VERSION ${STACK_VERSION}\033[0m" 47 | echo -e "\033[34;1mINFO:\033[0m OUTPUT_DIR ${OUTPUT_DIR}\033[0m" 48 | 49 | # ------------------------------------------------------- # 50 | # Parse Command 51 | # ------------------------------------------------------- # 52 | 53 | case $CMD in 54 | clean) 55 | echo -e "\033[36;1mTARGET: clean workspace $output_folder\033[0m" 56 | rm -rf "$output_folder" 57 | echo -e "\033[32;1mdone.\033[0m" 58 | exit 0 59 | ;; 60 | assemble) 61 | if [ -v $VERSION ]; then 62 | echo -e "\033[31;1mTARGET: assemble -> missing version parameter\033[0m" 63 | exit 1 64 | fi 65 | echo -e "\033[36;1mTARGET: assemble artefact $VERSION\033[0m" 66 | TASK=release 67 | TASK_ARGS=("$VERSION" "$output_folder") 68 | ;; 69 | codegen) 70 | if [ -v $VERSION ]; then 71 | echo -e "\033[31;1mTARGET: codegen -> missing version parameter\033[0m" 72 | exit 1 73 | fi 74 | echo -e "\033[36;1mTARGET: codegen API v$VERSION\033[0m" 75 | TASK=codegen 76 | # VERSION is BRANCH here for now 77 | TASK_ARGS=("$VERSION" "$codegen_folder") 78 | ;; 79 | docsgen) 80 | if [ -v $VERSION ]; then 81 | echo -e "\033[31;1mTARGET: docsgen -> missing version parameter\033[0m" 82 | exit 1 83 | fi 84 | echo -e "\033[36;1mTARGET: generate docs for $VERSION\033[0m" 85 | TASK=codegen 86 | # VERSION is BRANCH here for now 87 | TASK_ARGS=("$VERSION" "$codegen_folder") 88 | ;; 89 | examplesgen) 90 | echo -e "\033[36;1mTARGET: generate examples\033[0m" 91 | TASK=codegen 92 | # VERSION is BRANCH here for now 93 | TASK_ARGS=("$VERSION" "$codegen_folder") 94 | ;; 95 | bump) 96 | if [ -v $VERSION ]; then 97 | echo -e "\033[31;1mTARGET: bump -> missing version parameter\033[0m" 98 | exit 1 99 | fi 100 | echo -e "\033[36;1mTARGET: bump to version $VERSION\033[0m" 101 | TASK=bump 102 | # VERSION is BRANCH here for now 103 | TASK_ARGS=("$VERSION") 104 | ;; 105 | *) 106 | echo -e "\nUsage:\n\t $CMD is not supported right now\n" 107 | exit 1 108 | esac 109 | 110 | 111 | # ------------------------------------------------------- # 112 | # Build Container 113 | # ------------------------------------------------------- # 114 | 115 | echo -e "\033[34;1mINFO: building $product container\033[0m" 116 | 117 | #docker build --file .ci/DockerFile --tag ${product} \ 118 | # --build-arg USER_ID="$(id -u)" \ 119 | # --build-arg GROUP_ID="$(id -g)" . 120 | 121 | 122 | # ------------------------------------------------------- # 123 | # Run the Container 124 | # ------------------------------------------------------- # 125 | 126 | echo -e "\033[34;1mINFO: running $product container\033[0m" 127 | 128 | #docker run \ 129 | # --env "DOTNET_VERSION" \ 130 | # --name test-runner \ 131 | # --volume $REPO_BINDING \ 132 | # --rm \ 133 | # $product \ 134 | # /bin/bash -c "./build.sh $TASK ${TASK_ARGS[*]} && chown -R $(id -u):$(id -g) ." 135 | 136 | # ------------------------------------------------------- # 137 | # Post Command tasks & checks 138 | # ------------------------------------------------------- # 139 | 140 | if [[ "$CMD" == "assemble" ]]; then 141 | if compgen -G ".ci/output/*" > /dev/null; then 142 | echo -e "\033[32;1mTARGET: successfully assembled client v$VERSION\033[0m" 143 | else 144 | echo -e "\033[31;1mTARGET: assemble failed, empty workspace!\033[0m" 145 | exit 1 146 | fi 147 | fi 148 | 149 | if [[ "$CMD" == "bump" ]]; then 150 | echo "TODO" 151 | fi 152 | 153 | if [[ "$CMD" == "codegen" ]]; then 154 | echo "TODO" 155 | fi 156 | 157 | if [[ "$CMD" == "docsgen" ]]; then 158 | echo "TODO" 159 | fi 160 | 161 | if [[ "$CMD" == "examplesgen" ]]; then 162 | echo "TODO" 163 | fi -------------------------------------------------------------------------------- /.github/check-license-headers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Licensed to Elasticsearch B.V. under one or more agreements. 4 | # Elasticsearch B.V. licenses this file to you under the Apache 2.0 License. 5 | # See the LICENSE file in the project root for more information. 6 | 7 | # Check that source code files in this repo have the appropriate license 8 | # header. 9 | 10 | if [ "$TRACE" != "" ]; then 11 | export PS4='${BASH_SOURCE}:${LINENO}: ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' 12 | set -o xtrace 13 | fi 14 | set -o errexit 15 | set -o pipefail 16 | 17 | TOP=$(cd "$(dirname "$0")/.." >/dev/null && pwd) 18 | NLINES=$(wc -l .github/license-header.txt | awk '{print $1}') 19 | 20 | function check_license_header { 21 | local f 22 | f=$1 23 | if ! diff .github/license-header.txt <(head -$NLINES "$f") >/dev/null; then 24 | echo "check-license-headers: error: '$f' does not have required license header, see 'diff -u .github/license-header.txt <(head -$NLINES $f)'" 25 | return 1 26 | else 27 | return 0 28 | fi 29 | } 30 | 31 | 32 | cd "$TOP" 33 | nErrors=0 34 | for f in $(git ls-files | grep '\.go$'); do 35 | if ! check_license_header $f; then 36 | nErrors=$((nErrors+1)) 37 | fi 38 | done 39 | 40 | if [[ $nErrors -eq 0 ]]; then 41 | exit 0 42 | else 43 | exit 1 44 | fi 45 | -------------------------------------------------------------------------------- /.github/license-header.txt: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | -------------------------------------------------------------------------------- /.github/workflows/license.yml: -------------------------------------------------------------------------------- 1 | name: License headers 2 | on: [pull_request] 3 | jobs: 4 | license-check: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Check license headers 9 | run: ./.github/check-license-headers.sh 10 | -------------------------------------------------------------------------------- /.github/workflows/test-integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | 3 | on: [ push, pull_request ] 4 | 5 | env: 6 | GITHUB_ACTIONS: true 7 | ELASTICSEARCH_VERSION: elasticsearch:master-SNAPSHOT 8 | 9 | jobs: 10 | test-integ: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: { fetch-depth: 1 } 16 | - uses: actions/setup-go@v2.1.3 17 | with: { go-version: '1.x' } 18 | - run: go version 19 | # - name: Increase system limits 20 | # run: | 21 | # sudo swapoff -a 22 | # sudo sysctl -w vm.swappiness=1 23 | # sudo sysctl -w fs.file-max=262144 24 | # sudo sysctl -w vm.max_map_count=262144 25 | - name: Launch Elasticsearch 26 | run: | 27 | docker pull --quiet docker.elastic.co/elasticsearch/${{ env.ELASTICSEARCH_VERSION }} 28 | docker pull --quiet curlimages/curl 29 | docker network inspect elasticsearch > /dev/null 2>&1 || docker network create elasticsearch 30 | docker run --name es1 --rm --network elasticsearch -d -p 9200:9200 --env "xpack.security.enabled=false" --env "discovery.type=single-node" docker.elastic.co/elasticsearch/${{ env.ELASTICSEARCH_VERSION }} 31 | docker run --network elasticsearch --rm curlimages/curl --max-time 120 --retry 120 --retry-delay 1 --retry-all-errors --show-error --silent es1:9200 32 | - run: go test -v -race=true --tags=integration ./... 33 | - run: docker stop es1 34 | test-integ-multinode: 35 | name: Tests 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | with: { fetch-depth: 1 } 40 | - uses: actions/setup-go@v2.1.3 41 | with: { go-version: '1.x' } 42 | - run: go version 43 | # - name: Increase system limits 44 | # run: | 45 | # sudo swapoff -a 46 | # sudo sysctl -w vm.swappiness=1 47 | # sudo sysctl -w fs.file-max=262144 48 | # sudo sysctl -w vm.max_map_count=262144 49 | - name: Launch Elasticsearch 50 | run: | 51 | docker pull --quiet docker.elastic.co/elasticsearch/${{ env.ELASTICSEARCH_VERSION }} 52 | docker pull --quiet curlimages/curl 53 | docker network inspect elasticsearch > /dev/null 2>&1 || docker network create elasticsearch 54 | docker run --name es1 --rm --network elasticsearch -d -p 9200:9200 --env "node.name=es1" --env "xpack.security.enabled=false" --env "cluster.initial_master_nodes=es1" --env "discovery.seed_hosts=es1" docker.elastic.co/elasticsearch/${{ env.ELASTICSEARCH_VERSION }} 55 | docker run --name es2 --rm --network elasticsearch -d -p 9201:9200 --env "node.name=es2" --env "xpack.security.enabled=false" --env "cluster.initial_master_nodes=es1" --env "discovery.seed_hosts=es1" docker.elastic.co/elasticsearch/${{ env.ELASTICSEARCH_VERSION }} 56 | docker run --network elasticsearch --rm curlimages/curl --max-time 120 --retry 120 --retry-delay 1 --retry-all-errors --show-error --silent es1:9200 57 | - run: go test -v --tags=integration,multinode ./... 58 | - run: docker stop es1 es2 -------------------------------------------------------------------------------- /.github/workflows/test-unit.yml: -------------------------------------------------------------------------------- 1 | name: Unit 2 | 3 | on: [ pull_request,push ] 4 | 5 | env: 6 | GITHUB_ACTIONS: true 7 | 8 | jobs: 9 | test: 10 | name: "Tests (${{ matrix.os }})" 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ ubuntu-latest, windows-latest, macOS-latest ] 15 | go: [ '1.x' ] 16 | fail-fast: false 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: { fetch-depth: 1 } 20 | - uses: actions/setup-go@v2 21 | with: { go-version: "${{ matrix.go }}" } 22 | - run: go version 23 | - run: go test -v -race=true ./... 24 | 25 | bench: 26 | name: Benchmarks 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | with: { fetch-depth: 1 } 31 | - uses: actions/setup-go@v2 32 | with: { go-version: '1.x' } 33 | - run: go version 34 | - run: go test -bench=. ./... 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | 3 | #jetBrains editors 4 | .idea -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 303 See Other 2 | 3 | Location: https://www.elastic.co/community/codeofconduct 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Elastic Transport Go 2 | Copyright 2014-2021 Elasticsearch BV 3 | 4 | This product includes software developed by The Apache Software 5 | Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elastic-transport-go 2 | 3 | This library was lifted from elasticsearch-net and then transformed to be used across all Elastic services rather than 4 | only Elasticsearch. 5 | 6 | It provides the Transport interface used by `go-elasticsearch`, connection pool, cluster discovery, and multiple loggers. 7 | 8 | ## Installation 9 | 10 | Add the package to your go.mod file: 11 | 12 | `require github.com/elastic/elastic/transport-go/v8 main` 13 | 14 | ## Usage 15 | 16 | ### Transport 17 | The transport provides the basic layer to access Elasticsearch APIs. 18 | 19 | ```go 20 | package main 21 | 22 | import ( 23 | "log" 24 | "net/http" 25 | "net/url" 26 | 27 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 28 | ) 29 | 30 | func main() { 31 | u, _ := url.Parse("http://127.0.0.1:9200") 32 | 33 | cfg := elastictransport.Config{ 34 | URLs: []*url.URL{u}, 35 | } 36 | transport, err := elastictransport.New(cfg) 37 | if err != nil { 38 | log.Fatalln(err) 39 | } 40 | 41 | req, _ := http.NewRequest("GET", "/", nil) 42 | 43 | res, err := transport.Perform(req) 44 | if err != nil { 45 | log.Fatalln(err) 46 | } 47 | defer res.Body.Close() 48 | 49 | log.Println(res) 50 | } 51 | ``` 52 | 53 | > NOTE: It is _critical_ to both close the response body _and_ to consume it, in order to re-use persistent TCP connections in the default HTTP transport. If you're not interested in the response body, call `io.Copy(ioutil.Discard, res.Body)`. 54 | 55 | ### Discovery 56 | 57 | Discovery module calls the cluster to retrieve its complete list of nodes. 58 | 59 | Once your transport has been setup, you can easily trigger this behavior like so : 60 | 61 | ```go 62 | err := transport.DiscoverNodes() 63 | ``` 64 | 65 | ### Metrics 66 | 67 | Allows you to retrieve metrics directly from the transport. 68 | 69 | ### Loggers 70 | 71 | One of multiple loggers can be injected directly into the `Logger` configuration, these are as follow: 72 | 73 | #### TextLogger 74 | config: 75 | ```go 76 | cfg := elastictransport.Config{ 77 | elastictransport.TextLogger{os.Stdout, true, true}, 78 | } 79 | ``` 80 | output: 81 | ``` 82 | < { 83 | < "name" : "es", 84 | < "cluster_name" : "elasticsearch", 85 | < "cluster_uuid" : "RxB1iqTNT9q3LlIkTsmWRA", 86 | < "version" : { 87 | < "number" : "8.0.0-SNAPSHOT", 88 | < "build_flavor" : "default", 89 | < "build_type" : "docker", 90 | < "build_hash" : "0564e027dc6c69236937b1edcc04c207b4cd8128", 91 | < "build_date" : "2021-11-25T00:23:33.139514432Z", 92 | < "build_snapshot" : true, 93 | < "lucene_version" : "9.0.0", 94 | < "minimum_wire_compatibility_version" : "7.16.0", 95 | < "minimum_index_compatibility_version" : "7.0.0" 96 | < }, 97 | < "tagline" : "You Know, for Search" 98 | < } 99 | ``` 100 | 101 | #### JSONLogger 102 | config: 103 | ```go 104 | cfg := elastictransport.Config{ 105 | Logger: &elastictransport.JSONLogger{os.Stdout, true, true}, 106 | } 107 | ``` 108 | output: 109 | ```json 110 | { 111 | "@timestamp": "2021-11-25T16:33:51Z", 112 | "event": { 113 | "duration": 2892269 114 | }, 115 | "url": { 116 | "scheme": "http", 117 | "domain": "127.0.0.1", 118 | "port": 9200, 119 | "path": "/", 120 | "query": "" 121 | }, 122 | "http": { 123 | "request": { 124 | "method": "GET" 125 | }, 126 | "response": { 127 | "status_code": 200, 128 | "body": "{\n \"name\" : \"es1\",\n \"cluster_name\" : \"go-elasticsearch\",\n \"cluster_uuid\" : \"RxB1iqTNT9q3LlIkTsmWRA\",\n \"version\" : {\n \"number\" : \"8.0.0-SNAPSHOT\",\n \"build_flavor\" : \"default\",\n \"build_type\" : \"docker\",\n \"build_hash\" : \"0564e027dc6c69236937b1edcc04c207b4cd8128\",\n \"build_date\" : \"2021-11-25T00:23:33.139514432Z\",\n \"build_snapshot\" : true,\n \"lucene_version\" : \"9.0.0\",\n \"minimum_wire_compatibility_version\" : \"8.0.0\",\n \"minimum_index_compatibility_version\" : \"7.0.0\"\n },\n \"tagline\" : \"You Know, for Search\"\n}\n" 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | #### ColorLogger 135 | config: 136 | ```go 137 | cfg := elastictransport.Config{ 138 | Logger: &elastictransport.ColorLogger{os.Stdout, true, true}, 139 | } 140 | ``` 141 | output: 142 | ``` 143 | GET http://127.0.0.1:9200/ 200 OK 2ms 144 | « { 145 | « "name" : "es1", 146 | « "cluster_name" : "go-elasticsearch", 147 | « "cluster_uuid" : "RxB1iqTNT9q3LlIkTsmWRA", 148 | « "version" : { 149 | « "number" : "8.0.0-SNAPSHOT", 150 | « "build_flavor" : "default", 151 | « "build_type" : "docker", 152 | « "build_hash" : "0564e027dc6c69236937b1edcc04c207b4cd8128", 153 | « "build_date" : "2021-11-25T00:23:33.139514432Z", 154 | « "build_snapshot" : true, 155 | « "lucene_version" : "9.0.0", 156 | « "minimum_wire_compatibility_version" : "7.16.0", 157 | « "minimum_index_compatibility_version" : "7.0.0" 158 | « }, 159 | « "tagline" : "You Know, for Search" 160 | « } 161 | ──────────────────────────────────────────────────────────────────────────────── 162 | ``` 163 | 164 | ### CurlLogger 165 | config: 166 | ```go 167 | cfg := elastictransport.Config{ 168 | Logger: &elastictransport.CurlLogger{os.Stdout, true, true}, 169 | } 170 | ``` 171 | output: 172 | ```shell 173 | curl -X GET 'http://localhost:9200/?pretty' 174 | # => 2021-11-25T16:40:11Z [200 OK] 3ms 175 | # { 176 | # "name": "es1", 177 | # "cluster_name": "go-elasticsearch", 178 | # "cluster_uuid": "RxB1iqTNT9q3LlIkTsmWRA", 179 | # "version": { 180 | # "number": "8.0.0-SNAPSHOT", 181 | # "build_flavor": "default", 182 | # "build_type": "docker", 183 | # "build_hash": "0564e027dc6c69236937b1edcc04c207b4cd8128", 184 | # "build_date": "2021-11-25T00:23:33.139514432Z", 185 | # "build_snapshot": true, 186 | # "lucene_version": "9.0.0", 187 | # "minimum_wire_compatibility_version": "7.16.0", 188 | # "minimum_index_compatibility_version": "7.0.0" 189 | # }, 190 | # "tagline": "You Know, for Search" 191 | # } 192 | ``` 193 | 194 | # License 195 | 196 | Licensed under the Apache License, Version 2.0. -------------------------------------------------------------------------------- /elastictransport/connection.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | "math" 24 | "net/url" 25 | "sort" 26 | "sync" 27 | "time" 28 | ) 29 | 30 | var ( 31 | defaultResurrectTimeoutInitial = 60 * time.Second 32 | defaultResurrectTimeoutFactorCutoff = 5 33 | ) 34 | 35 | // Selector defines the interface for selecting connections from the pool. 36 | type Selector interface { 37 | Select([]*Connection) (*Connection, error) 38 | } 39 | 40 | // ConnectionPool defines the interface for the connection pool. 41 | type ConnectionPool interface { 42 | Next() (*Connection, error) // Next returns the next available connection. 43 | OnSuccess(*Connection) error // OnSuccess reports that the connection was successful. 44 | OnFailure(*Connection) error // OnFailure reports that the connection failed. 45 | URLs() []*url.URL // URLs returns the list of URLs of available connections. 46 | } 47 | 48 | type UpdatableConnectionPool interface { 49 | Update([]*Connection) error // Update injects newly found nodes in the cluster. 50 | } 51 | 52 | // Connection represents a connection to a node. 53 | type Connection struct { 54 | sync.Mutex 55 | 56 | URL *url.URL 57 | IsDead bool 58 | DeadSince time.Time 59 | Failures int 60 | 61 | ID string 62 | Name string 63 | Roles []string 64 | Attributes map[string]interface{} 65 | } 66 | 67 | func (c *Connection) Cmp(connection *Connection) bool { 68 | if c.URL.Hostname() == connection.URL.Hostname() { 69 | if c.URL.Port() == connection.URL.Port() { 70 | return c.URL.Path == connection.URL.Path 71 | } 72 | } 73 | return false 74 | } 75 | 76 | type singleConnectionPool struct { 77 | connection *Connection 78 | 79 | metrics *metrics 80 | } 81 | 82 | type statusConnectionPool struct { 83 | sync.Mutex 84 | 85 | live []*Connection // List of live connections 86 | dead []*Connection // List of dead connections 87 | selector Selector 88 | 89 | metrics *metrics 90 | } 91 | 92 | type roundRobinSelector struct { 93 | sync.Mutex 94 | 95 | curr int // Index of the current connection 96 | } 97 | 98 | // NewConnectionPool creates and returns a default connection pool. 99 | func NewConnectionPool(conns []*Connection, selector Selector) (ConnectionPool, error) { 100 | if len(conns) == 1 { 101 | return &singleConnectionPool{connection: conns[0]}, nil 102 | } 103 | if selector == nil { 104 | selector = &roundRobinSelector{curr: -1} 105 | } 106 | return &statusConnectionPool{live: conns, selector: selector}, nil 107 | } 108 | 109 | // Next returns the connection from pool. 110 | func (cp *singleConnectionPool) Next() (*Connection, error) { 111 | return cp.connection, nil 112 | } 113 | 114 | // OnSuccess is a no-op for single connection pool. 115 | func (cp *singleConnectionPool) OnSuccess(c *Connection) error { return nil } 116 | 117 | // OnFailure is a no-op for single connection pool. 118 | func (cp *singleConnectionPool) OnFailure(c *Connection) error { return nil } 119 | 120 | // URLs returns the list of URLs of available connections. 121 | func (cp *singleConnectionPool) URLs() []*url.URL { return []*url.URL{cp.connection.URL} } 122 | 123 | func (cp *singleConnectionPool) connections() []*Connection { return []*Connection{cp.connection} } 124 | 125 | // Next returns a connection from pool, or an error. 126 | func (cp *statusConnectionPool) Next() (*Connection, error) { 127 | cp.Lock() 128 | defer cp.Unlock() 129 | 130 | // Return next live connection 131 | if len(cp.live) > 0 { 132 | return cp.selector.Select(cp.live) 133 | } else if len(cp.dead) > 0 { 134 | // No live connection is available, resurrect one of the dead ones. 135 | c := cp.dead[len(cp.dead)-1] 136 | cp.dead = cp.dead[:len(cp.dead)-1] 137 | c.Lock() 138 | defer c.Unlock() 139 | cp.resurrect(c, false) 140 | return c, nil 141 | } 142 | return nil, errors.New("no connection available") 143 | } 144 | 145 | // OnSuccess marks the connection as successful. 146 | func (cp *statusConnectionPool) OnSuccess(c *Connection) error { 147 | // Short-circuit for live connection 148 | c.Lock() 149 | if !c.IsDead { 150 | c.Unlock() 151 | return nil 152 | } 153 | c.Unlock() 154 | 155 | cp.Lock() 156 | defer cp.Unlock() 157 | 158 | c.Lock() 159 | defer c.Unlock() 160 | 161 | if !c.IsDead { 162 | return nil 163 | } 164 | 165 | c.markAsHealthy() 166 | return cp.resurrect(c, true) 167 | } 168 | 169 | // OnFailure marks the connection as failed. 170 | func (cp *statusConnectionPool) OnFailure(c *Connection) error { 171 | cp.Lock() 172 | defer cp.Unlock() 173 | 174 | c.Lock() 175 | 176 | if c.IsDead { 177 | if debugLogger != nil { 178 | debugLogger.Logf("Already removed %s\n", c.URL) 179 | } 180 | c.Unlock() 181 | return nil 182 | } 183 | 184 | if debugLogger != nil { 185 | debugLogger.Logf("Removing %s...\n", c.URL) 186 | } 187 | c.markAsDead() 188 | cp.scheduleResurrect(c) 189 | c.Unlock() 190 | 191 | // Check if connection exists in the list, return error if not. 192 | index := -1 193 | for i, conn := range cp.live { 194 | if conn == c { 195 | index = i 196 | } 197 | } 198 | if index < 0 { 199 | return errors.New("connection not in live list") 200 | } 201 | 202 | // Push item to dead list and sort slice by number of failures 203 | cp.dead = append(cp.dead, c) 204 | sort.Slice(cp.dead, func(i, j int) bool { 205 | c1 := cp.dead[i] 206 | c2 := cp.dead[j] 207 | 208 | res := c1.Failures > c2.Failures 209 | return res 210 | }) 211 | 212 | // Remove item; https://github.com/golang/go/wiki/SliceTricks 213 | copy(cp.live[index:], cp.live[index+1:]) 214 | cp.live = cp.live[:len(cp.live)-1] 215 | 216 | return nil 217 | } 218 | 219 | // Update merges the existing live and dead connections with the latest nodes discovered from the cluster. 220 | // ConnectionPool must be locked before calling. 221 | func (cp *statusConnectionPool) Update(connections []*Connection) error { 222 | if len(connections) == 0 { 223 | return errors.New("no connections provided, connection pool left untouched") 224 | } 225 | 226 | // Remove hosts that are no longer in the new list of connections 227 | for i := 0; i < len(cp.live); i++ { 228 | found := false 229 | for _, c := range connections { 230 | if cp.live[i].Cmp(c) { 231 | found = true 232 | break 233 | } 234 | } 235 | 236 | if !found { 237 | // Remove item; https://github.com/golang/go/wiki/SliceTricks 238 | copy(cp.live[i:], cp.live[i+1:]) 239 | cp.live = cp.live[:len(cp.live)-1] 240 | i-- 241 | } 242 | } 243 | 244 | // Remove hosts that are no longer in the dead list of connections 245 | for i := 0; i < len(cp.dead); i++ { 246 | found := false 247 | for _, c := range connections { 248 | if cp.dead[i].Cmp(c) { 249 | found = true 250 | break 251 | } 252 | } 253 | 254 | if !found { 255 | copy(cp.dead[i:], cp.dead[i+1:]) 256 | cp.dead = cp.dead[:len(cp.dead)-1] 257 | i-- 258 | } 259 | } 260 | 261 | // Add new connections that are not already in the live or dead list 262 | for _, c := range connections { 263 | found := false 264 | for _, conn := range cp.live { 265 | if conn.Cmp(c) { 266 | found = true 267 | break 268 | } 269 | } 270 | for _, conn := range cp.dead { 271 | if conn.Cmp(c) { 272 | found = true 273 | break 274 | } 275 | } 276 | if !found { 277 | cp.live = append(cp.live, c) 278 | } 279 | } 280 | 281 | return nil 282 | } 283 | 284 | // URLs returns the list of URLs of available connections. 285 | func (cp *statusConnectionPool) URLs() []*url.URL { 286 | var urls []*url.URL 287 | 288 | cp.Lock() 289 | defer cp.Unlock() 290 | 291 | for _, c := range cp.live { 292 | urls = append(urls, c.URL) 293 | } 294 | 295 | return urls 296 | } 297 | 298 | func (cp *statusConnectionPool) connections() []*Connection { 299 | var conns []*Connection 300 | conns = append(conns, cp.live...) 301 | conns = append(conns, cp.dead...) 302 | return conns 303 | } 304 | 305 | // resurrect adds the connection to the list of available connections. 306 | // When removeDead is true, it also removes it from the dead list. 307 | // The calling code is responsible for locking. 308 | func (cp *statusConnectionPool) resurrect(c *Connection, removeDead bool) error { 309 | if debugLogger != nil { 310 | debugLogger.Logf("Resurrecting %s\n", c.URL) 311 | } 312 | 313 | c.markAsLive() 314 | cp.live = append(cp.live, c) 315 | 316 | if removeDead { 317 | index := -1 318 | for i, conn := range cp.dead { 319 | if conn == c { 320 | index = i 321 | } 322 | } 323 | if index >= 0 { 324 | // Remove item; https://github.com/golang/go/wiki/SliceTricks 325 | copy(cp.dead[index:], cp.dead[index+1:]) 326 | cp.dead = cp.dead[:len(cp.dead)-1] 327 | } 328 | } 329 | 330 | return nil 331 | } 332 | 333 | // scheduleResurrect schedules the connection to be resurrected. 334 | func (cp *statusConnectionPool) scheduleResurrect(c *Connection) { 335 | factor := math.Min(float64(c.Failures-1), float64(defaultResurrectTimeoutFactorCutoff)) 336 | timeout := time.Duration(defaultResurrectTimeoutInitial.Seconds() * math.Exp2(factor) * float64(time.Second)) 337 | if debugLogger != nil { 338 | debugLogger.Logf("Resurrect %s (failures=%d, factor=%1.1f, timeout=%s) in %s\n", c.URL, c.Failures, factor, timeout, c.DeadSince.Add(timeout).Sub(time.Now().UTC()).Truncate(time.Second)) 339 | } 340 | 341 | time.AfterFunc(timeout, func() { 342 | cp.Lock() 343 | defer cp.Unlock() 344 | 345 | c.Lock() 346 | defer c.Unlock() 347 | 348 | if !c.IsDead { 349 | if debugLogger != nil { 350 | debugLogger.Logf("Already resurrected %s\n", c.URL) 351 | } 352 | return 353 | } 354 | 355 | cp.resurrect(c, true) 356 | }) 357 | } 358 | 359 | // Select returns the connection in a round-robin fashion. 360 | func (s *roundRobinSelector) Select(conns []*Connection) (*Connection, error) { 361 | s.Lock() 362 | defer s.Unlock() 363 | 364 | s.curr = (s.curr + 1) % len(conns) 365 | return conns[s.curr], nil 366 | } 367 | 368 | // markAsDead marks the connection as dead. 369 | func (c *Connection) markAsDead() { 370 | c.IsDead = true 371 | if c.DeadSince.IsZero() { 372 | c.DeadSince = time.Now().UTC() 373 | } 374 | c.Failures++ 375 | } 376 | 377 | // markAsLive marks the connection as alive. 378 | func (c *Connection) markAsLive() { 379 | c.IsDead = false 380 | } 381 | 382 | // markAsHealthy marks the connection as healthy. 383 | func (c *Connection) markAsHealthy() { 384 | c.IsDead = false 385 | c.DeadSince = time.Time{} 386 | c.Failures = 0 387 | } 388 | 389 | // String returns a readable connection representation. 390 | func (c *Connection) String() string { 391 | c.Lock() 392 | defer c.Unlock() 393 | return fmt.Sprintf("<%s> dead=%v failures=%d", c.URL, c.IsDead, c.Failures) 394 | } 395 | -------------------------------------------------------------------------------- /elastictransport/connection_benchmark_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "fmt" 25 | "log" 26 | "net/http" 27 | "net/url" 28 | "testing" 29 | 30 | _ "net/http/pprof" 31 | ) 32 | 33 | func init() { 34 | go func() { log.Fatalln(http.ListenAndServe("localhost:6060", nil)) }() 35 | } 36 | 37 | func BenchmarkSingleConnectionPool(b *testing.B) { 38 | b.ReportAllocs() 39 | 40 | b.Run("Next()", func(b *testing.B) { 41 | pool := &singleConnectionPool{connection: &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}} 42 | 43 | b.Run("Single ", func(b *testing.B) { 44 | for i := 0; i < b.N; i++ { 45 | _, err := pool.Next() 46 | if err != nil { 47 | b.Errorf("Unexpected error: %v", err) 48 | } 49 | } 50 | }) 51 | 52 | b.Run("Parallel (1000)", func(b *testing.B) { 53 | b.SetParallelism(1000) 54 | b.RunParallel(func(pb *testing.PB) { 55 | for pb.Next() { 56 | _, err := pool.Next() 57 | if err != nil { 58 | b.Errorf("Unexpected error: %v", err) 59 | } 60 | } 61 | }) 62 | }) 63 | }) 64 | 65 | b.Run("OnFailure()", func(b *testing.B) { 66 | pool := &singleConnectionPool{connection: &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}} 67 | 68 | b.Run("Single ", func(b *testing.B) { 69 | c, _ := pool.Next() 70 | 71 | for i := 0; i < b.N; i++ { 72 | if err := pool.OnFailure(c); err != nil { 73 | b.Errorf("Unexpected error: %v", err) 74 | } 75 | } 76 | }) 77 | 78 | b.Run("Parallel (1000)", func(b *testing.B) { 79 | b.SetParallelism(1000) 80 | b.RunParallel(func(pb *testing.PB) { 81 | c, _ := pool.Next() 82 | 83 | for pb.Next() { 84 | if err := pool.OnFailure(c); err != nil { 85 | b.Errorf("Unexpected error: %v", err) 86 | } 87 | } 88 | }) 89 | }) 90 | }) 91 | } 92 | 93 | func BenchmarkStatusConnectionPool(b *testing.B) { 94 | b.ReportAllocs() 95 | 96 | var conns []*Connection 97 | for i := 0; i < 1000; i++ { 98 | conns = append(conns, &Connection{URL: &url.URL{Scheme: "http", Host: fmt.Sprintf("foo%d", i)}}) 99 | } 100 | 101 | b.Run("Next()", func(b *testing.B) { 102 | pool := &statusConnectionPool{ 103 | live: conns, 104 | selector: &roundRobinSelector{curr: -1}, 105 | } 106 | 107 | b.Run("Single ", func(b *testing.B) { 108 | for i := 0; i < b.N; i++ { 109 | _, err := pool.Next() 110 | if err != nil { 111 | b.Errorf("Unexpected error: %v", err) 112 | } 113 | } 114 | }) 115 | 116 | b.Run("Parallel (100)", func(b *testing.B) { 117 | b.SetParallelism(100) 118 | b.RunParallel(func(pb *testing.PB) { 119 | for pb.Next() { 120 | _, err := pool.Next() 121 | if err != nil { 122 | b.Errorf("Unexpected error: %v", err) 123 | } 124 | } 125 | }) 126 | }) 127 | 128 | b.Run("Parallel (1000)", func(b *testing.B) { 129 | b.SetParallelism(1000) 130 | b.RunParallel(func(pb *testing.PB) { 131 | for pb.Next() { 132 | _, err := pool.Next() 133 | if err != nil { 134 | b.Errorf("Unexpected error: %v", err) 135 | } 136 | } 137 | }) 138 | }) 139 | }) 140 | 141 | b.Run("OnFailure()", func(b *testing.B) { 142 | pool := &statusConnectionPool{ 143 | live: conns, 144 | selector: &roundRobinSelector{curr: -1}, 145 | } 146 | 147 | b.Run("Single ", func(b *testing.B) { 148 | c, err := pool.Next() 149 | if err != nil { 150 | b.Fatalf("Unexpected error: %s", err) 151 | } 152 | 153 | for i := 0; i < b.N; i++ { 154 | if err := pool.OnFailure(c); err != nil { 155 | b.Errorf("Unexpected error: %v", err) 156 | } 157 | } 158 | }) 159 | 160 | b.Run("Parallel (10)", func(b *testing.B) { 161 | b.SetParallelism(10) 162 | b.RunParallel(func(pb *testing.PB) { 163 | c, err := pool.Next() 164 | if err != nil { 165 | b.Fatalf("Unexpected error: %s", err) 166 | } 167 | 168 | for pb.Next() { 169 | if err := pool.OnFailure(c); err != nil { 170 | b.Errorf("Unexpected error: %v", err) 171 | } 172 | } 173 | }) 174 | }) 175 | 176 | b.Run("Parallel (100)", func(b *testing.B) { 177 | b.SetParallelism(100) 178 | b.RunParallel(func(pb *testing.PB) { 179 | c, err := pool.Next() 180 | if err != nil { 181 | b.Fatalf("Unexpected error: %s", err) 182 | } 183 | 184 | for pb.Next() { 185 | if err := pool.OnFailure(c); err != nil { 186 | b.Errorf("Unexpected error: %v", err) 187 | } 188 | } 189 | }) 190 | }) 191 | }) 192 | 193 | b.Run("OnSuccess()", func(b *testing.B) { 194 | pool := &statusConnectionPool{ 195 | live: conns, 196 | selector: &roundRobinSelector{curr: -1}, 197 | } 198 | 199 | b.Run("Single ", func(b *testing.B) { 200 | c, err := pool.Next() 201 | if err != nil { 202 | b.Fatalf("Unexpected error: %s", err) 203 | } 204 | 205 | for i := 0; i < b.N; i++ { 206 | if err := pool.OnSuccess(c); err != nil { 207 | b.Errorf("Unexpected error: %v", err) 208 | } 209 | } 210 | }) 211 | 212 | b.Run("Parallel (10)", func(b *testing.B) { 213 | b.SetParallelism(10) 214 | b.RunParallel(func(pb *testing.PB) { 215 | c, err := pool.Next() 216 | if err != nil { 217 | b.Fatalf("Unexpected error: %s", err) 218 | } 219 | 220 | for pb.Next() { 221 | if err := pool.OnSuccess(c); err != nil { 222 | b.Errorf("Unexpected error: %v", err) 223 | } 224 | } 225 | }) 226 | }) 227 | 228 | b.Run("Parallel (100)", func(b *testing.B) { 229 | b.SetParallelism(100) 230 | b.RunParallel(func(pb *testing.PB) { 231 | c, err := pool.Next() 232 | if err != nil { 233 | b.Fatalf("Unexpected error: %s", err) 234 | } 235 | 236 | for pb.Next() { 237 | if err := pool.OnSuccess(c); err != nil { 238 | b.Errorf("Unexpected error: %v", err) 239 | } 240 | } 241 | }) 242 | }) 243 | }) 244 | 245 | b.Run("resurrect()", func(b *testing.B) { 246 | pool := &statusConnectionPool{ 247 | live: conns, 248 | selector: &roundRobinSelector{curr: -1}, 249 | } 250 | 251 | b.Run("Single", func(b *testing.B) { 252 | c, err := pool.Next() 253 | if err != nil { 254 | b.Fatalf("Unexpected error: %s", err) 255 | } 256 | err = pool.OnFailure(c) 257 | if err != nil { 258 | b.Fatalf("Unexpected error: %s", err) 259 | } 260 | 261 | for i := 0; i < b.N; i++ { 262 | pool.Lock() 263 | if err := pool.resurrect(c, true); err != nil { 264 | b.Errorf("Unexpected error: %v", err) 265 | } 266 | pool.Unlock() 267 | } 268 | }) 269 | 270 | b.Run("Parallel (10)", func(b *testing.B) { 271 | b.SetParallelism(10) 272 | b.RunParallel(func(pb *testing.PB) { 273 | c, err := pool.Next() 274 | if err != nil { 275 | b.Fatalf("Unexpected error: %s", err) 276 | } 277 | err = pool.OnFailure(c) 278 | if err != nil { 279 | b.Fatalf("Unexpected error: %s", err) 280 | } 281 | 282 | for pb.Next() { 283 | pool.Lock() 284 | if err := pool.resurrect(c, true); err != nil { 285 | b.Errorf("Unexpected error: %v", err) 286 | } 287 | pool.Unlock() 288 | } 289 | }) 290 | }) 291 | 292 | b.Run("Parallel (100)", func(b *testing.B) { 293 | b.SetParallelism(100) 294 | b.RunParallel(func(pb *testing.PB) { 295 | c, err := pool.Next() 296 | if err != nil { 297 | b.Fatalf("Unexpected error: %s", err) 298 | } 299 | err = pool.OnFailure(c) 300 | if err != nil { 301 | b.Fatalf("Unexpected error: %s", err) 302 | } 303 | 304 | for pb.Next() { 305 | pool.Lock() 306 | if err := pool.resurrect(c, true); err != nil { 307 | b.Errorf("Unexpected error: %v", err) 308 | } 309 | pool.Unlock() 310 | } 311 | }) 312 | }) 313 | }) 314 | } 315 | -------------------------------------------------------------------------------- /elastictransport/connection_integration_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build integration 19 | // +build integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "fmt" 25 | "net/http" 26 | "net/url" 27 | "os" 28 | "testing" 29 | "time" 30 | ) 31 | 32 | func NewServer(addr string, handler http.Handler) *http.Server { 33 | return &http.Server{Addr: addr, Handler: handler} 34 | } 35 | 36 | func TestStatusConnectionPool(t *testing.T) { 37 | defaultResurrectTimeoutInitial = time.Second 38 | defer func() { defaultResurrectTimeoutInitial = 60 * time.Second }() 39 | 40 | var ( 41 | server *http.Server 42 | servers []*http.Server 43 | serverURLs []*url.URL 44 | serverHosts []string 45 | numServers = 3 46 | 47 | defaultHandler = func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "OK") } 48 | ) 49 | 50 | for i := 1; i <= numServers; i++ { 51 | s := NewServer(fmt.Sprintf("localhost:1000%d", i), http.HandlerFunc(defaultHandler)) 52 | 53 | go func(s *http.Server) { 54 | if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { 55 | t.Fatalf("Unable to start server: %s", err) 56 | } 57 | }(s) 58 | 59 | defer func(s *http.Server) { s.Close() }(s) 60 | 61 | servers = append(servers, s) 62 | time.Sleep(time.Millisecond) 63 | } 64 | 65 | for _, s := range servers { 66 | u, _ := url.Parse("http://" + s.Addr) 67 | serverURLs = append(serverURLs, u) 68 | serverHosts = append(serverHosts, u.String()) 69 | } 70 | 71 | fmt.Printf("==> Started %d servers on %s\n", numServers, serverHosts) 72 | 73 | cfg := Config{URLs: serverURLs} 74 | 75 | if _, ok := os.LookupEnv("GITHUB_ACTIONS"); !ok { 76 | cfg.Logger = &TextLogger{Output: os.Stdout} 77 | cfg.EnableDebugLogger = true 78 | } 79 | 80 | transport, _ := New(cfg) 81 | 82 | pool := transport.pool.(*statusConnectionPool) 83 | 84 | for i := 1; i <= 9; i++ { 85 | req, _ := http.NewRequest("GET", "/", nil) 86 | res, err := transport.Perform(req) 87 | if err != nil { 88 | t.Errorf("Unexpected error: %v", err) 89 | } 90 | if res.StatusCode != 200 { 91 | t.Errorf("Unexpected status code, want=200, got=%d", res.StatusCode) 92 | } 93 | } 94 | 95 | pool.Lock() 96 | if len(pool.live) != 3 { 97 | t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.live)) 98 | } 99 | pool.Unlock() 100 | 101 | server = servers[1] 102 | fmt.Printf("==> Closing server: %s\n", server.Addr) 103 | if err := server.Close(); err != nil { 104 | t.Fatalf("Unable to close server: %s", err) 105 | } 106 | 107 | for i := 1; i <= 9; i++ { 108 | req, _ := http.NewRequest("GET", "/", nil) 109 | res, err := transport.Perform(req) 110 | if err != nil { 111 | t.Errorf("Unexpected error: %v", err) 112 | } 113 | if res.StatusCode != 200 { 114 | t.Errorf("Unexpected status code, want=200, got=%d", res.StatusCode) 115 | } 116 | } 117 | 118 | pool.Lock() 119 | if len(pool.live) != 2 { 120 | t.Errorf("Unexpected number of live connections, want=2, got=%d", len(pool.live)) 121 | } 122 | pool.Unlock() 123 | 124 | pool.Lock() 125 | if len(pool.dead) != 1 { 126 | t.Errorf("Unexpected number of dead connections, want=1, got=%d", len(pool.dead)) 127 | } 128 | pool.Unlock() 129 | 130 | server = NewServer("localhost:10002", http.HandlerFunc(defaultHandler)) 131 | servers[1] = server 132 | fmt.Printf("==> Starting server: %s\n", server.Addr) 133 | go func() { 134 | if err := server.ListenAndServe(); err != nil { 135 | t.Fatalf("Unable to start server: %s", err) 136 | } 137 | }() 138 | 139 | fmt.Println("==> Waiting 1.25s for resurrection...") 140 | time.Sleep(1250 * time.Millisecond) 141 | 142 | for i := 1; i <= 9; i++ { 143 | req, _ := http.NewRequest("GET", "/", nil) 144 | res, err := transport.Perform(req) 145 | if err != nil { 146 | t.Errorf("Unexpected error: %v", err) 147 | } 148 | if res.StatusCode != 200 { 149 | t.Errorf("Unexpected status code, want=200, got=%d", res.StatusCode) 150 | } 151 | } 152 | 153 | pool.Lock() 154 | if len(pool.live) != 3 { 155 | t.Errorf("Unexpected number of live connections, want=3, got=%d", len(pool.live)) 156 | } 157 | pool.Unlock() 158 | 159 | pool.Lock() 160 | if len(pool.dead) != 0 { 161 | t.Errorf("Unexpected number of dead connections, want=0, got=%d", len(pool.dead)) 162 | } 163 | pool.Unlock() 164 | } 165 | -------------------------------------------------------------------------------- /elastictransport/connection_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "fmt" 25 | "net/url" 26 | "regexp" 27 | "testing" 28 | "time" 29 | ) 30 | 31 | func TestSingleConnectionPoolNext(t *testing.T) { 32 | t.Run("Single URL", func(t *testing.T) { 33 | pool := &singleConnectionPool{ 34 | connection: &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 35 | } 36 | 37 | for i := 0; i < 7; i++ { 38 | c, err := pool.Next() 39 | if err != nil { 40 | t.Errorf("Unexpected error: %s", err) 41 | } 42 | 43 | if c.URL.String() != "http://foo1" { 44 | t.Errorf("Unexpected URL, want=http://foo1, got=%s", c.URL) 45 | } 46 | } 47 | }) 48 | } 49 | 50 | func TestSingleConnectionPoolOnFailure(t *testing.T) { 51 | t.Run("Noop", func(t *testing.T) { 52 | pool := &singleConnectionPool{ 53 | connection: &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 54 | } 55 | 56 | if err := pool.OnFailure(&Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}); err != nil { 57 | t.Errorf("Unexpected error: %s", err) 58 | } 59 | }) 60 | } 61 | 62 | func TestStatusConnectionPoolNext(t *testing.T) { 63 | t.Run("No URL", func(t *testing.T) { 64 | pool := &statusConnectionPool{} 65 | 66 | c, err := pool.Next() 67 | if err == nil { 68 | t.Errorf("Expected error, but got: %s", c.URL) 69 | } 70 | }) 71 | 72 | t.Run("Two URLs", func(t *testing.T) { 73 | var c *Connection 74 | 75 | pool := &statusConnectionPool{ 76 | live: []*Connection{ 77 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 78 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo2"}}, 79 | }, 80 | selector: &roundRobinSelector{curr: -1}, 81 | } 82 | 83 | c, _ = pool.Next() 84 | 85 | if c.URL.String() != "http://foo1" { 86 | t.Errorf("Unexpected URL, want=foo1, got=%s", c.URL) 87 | } 88 | 89 | c, _ = pool.Next() 90 | if c.URL.String() != "http://foo2" { 91 | t.Errorf("Unexpected URL, want=http://foo2, got=%s", c.URL) 92 | } 93 | 94 | c, _ = pool.Next() 95 | if c.URL.String() != "http://foo1" { 96 | t.Errorf("Unexpected URL, want=http://foo1, got=%s", c.URL) 97 | } 98 | }) 99 | 100 | t.Run("Three URLs", func(t *testing.T) { 101 | pool := &statusConnectionPool{ 102 | live: []*Connection{ 103 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 104 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo2"}}, 105 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo3"}}, 106 | }, 107 | selector: &roundRobinSelector{curr: -1}, 108 | } 109 | 110 | var expected string 111 | for i := 0; i < 11; i++ { 112 | c, err := pool.Next() 113 | 114 | if err != nil { 115 | t.Errorf("Unexpected error: %s", err) 116 | } 117 | 118 | switch i % len(pool.live) { 119 | case 0: 120 | expected = "http://foo1" 121 | case 1: 122 | expected = "http://foo2" 123 | case 2: 124 | expected = "http://foo3" 125 | default: 126 | t.Fatalf("Unexpected i %% 3: %d", i%3) 127 | } 128 | 129 | if c.URL.String() != expected { 130 | t.Errorf("Unexpected URL, want=%s, got=%s", expected, c.URL) 131 | } 132 | } 133 | }) 134 | 135 | t.Run("Resurrect dead connection when no live is available", func(t *testing.T) { 136 | pool := &statusConnectionPool{ 137 | live: []*Connection{}, 138 | dead: []*Connection{ 139 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}, Failures: 3}, 140 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo2"}, Failures: 1}, 141 | }, 142 | selector: &roundRobinSelector{curr: -1}, 143 | } 144 | 145 | c, err := pool.Next() 146 | if err != nil { 147 | t.Errorf("Unexpected error: %s", err) 148 | } 149 | 150 | if c == nil { 151 | t.Errorf("Expected connection, got nil: %s", c) 152 | } 153 | 154 | if c.URL.String() != "http://foo2" { 155 | t.Errorf("Expected , got: %s", c.URL.String()) 156 | } 157 | 158 | if c.IsDead { 159 | t.Errorf("Expected connection to be live, got: %s", c) 160 | } 161 | 162 | if len(pool.live) != 1 { 163 | t.Errorf("Expected 1 connection in live list, got: %s", pool.live) 164 | } 165 | 166 | if len(pool.dead) != 1 { 167 | t.Errorf("Expected 1 connection in dead list, got: %s", pool.dead) 168 | } 169 | }) 170 | } 171 | 172 | func TestStatusConnectionPoolOnSuccess(t *testing.T) { 173 | t.Run("Move connection to live list and mark it as healthy", func(t *testing.T) { 174 | pool := &statusConnectionPool{ 175 | dead: []*Connection{ 176 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}, Failures: 3, IsDead: true}, 177 | }, 178 | selector: &roundRobinSelector{curr: -1}, 179 | } 180 | 181 | conn := pool.dead[0] 182 | 183 | if err := pool.OnSuccess(conn); err != nil { 184 | t.Fatalf("Unexpected error: %s", err) 185 | } 186 | 187 | if conn.IsDead { 188 | t.Errorf("Expected the connection to be live; %s", conn) 189 | } 190 | 191 | if !conn.DeadSince.IsZero() { 192 | t.Errorf("Unexpected value for DeadSince: %s", conn.DeadSince) 193 | } 194 | 195 | if len(pool.live) != 1 { 196 | t.Errorf("Expected 1 live connection, got: %d", len(pool.live)) 197 | } 198 | 199 | if len(pool.dead) != 0 { 200 | t.Errorf("Expected 0 dead connections, got: %d", len(pool.dead)) 201 | } 202 | }) 203 | } 204 | 205 | func TestStatusConnectionPoolOnFailure(t *testing.T) { 206 | t.Run("Remove connection, mark it, and sort dead connections", func(t *testing.T) { 207 | pool := &statusConnectionPool{ 208 | live: []*Connection{ 209 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 210 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo2"}}, 211 | }, 212 | dead: []*Connection{ 213 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo3"}, Failures: 0}, 214 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo4"}, Failures: 99}, 215 | }, 216 | selector: &roundRobinSelector{curr: -1}, 217 | } 218 | 219 | conn := pool.live[0] 220 | 221 | if err := pool.OnFailure(conn); err != nil { 222 | t.Fatalf("Unexpected error: %s", err) 223 | } 224 | 225 | if !conn.IsDead { 226 | t.Errorf("Expected the connection to be dead; %s", conn) 227 | } 228 | 229 | if conn.DeadSince.IsZero() { 230 | t.Errorf("Unexpected value for DeadSince: %s", conn.DeadSince) 231 | } 232 | 233 | if len(pool.live) != 1 { 234 | t.Errorf("Expected 1 live connection, got: %d", len(pool.live)) 235 | } 236 | 237 | if len(pool.dead) != 3 { 238 | t.Errorf("Expected 3 dead connections, got: %d", len(pool.dead)) 239 | } 240 | 241 | expected := []string{ 242 | "http://foo4", 243 | "http://foo1", 244 | "http://foo3", 245 | } 246 | 247 | for i, u := range expected { 248 | if pool.dead[i].URL.String() != u { 249 | t.Errorf("Unexpected value for item %d in pool.dead: %s", i, pool.dead[i].URL.String()) 250 | } 251 | } 252 | }) 253 | 254 | t.Run("Short circuit when the connection is already dead", func(t *testing.T) { 255 | pool := &statusConnectionPool{ 256 | live: []*Connection{ 257 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}}, 258 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo2"}}, 259 | &Connection{URL: &url.URL{Scheme: "http", Host: "foo3"}}, 260 | }, 261 | selector: &roundRobinSelector{curr: -1}, 262 | } 263 | 264 | conn := pool.live[0] 265 | conn.IsDead = true 266 | 267 | if err := pool.OnFailure(conn); err != nil { 268 | t.Fatalf("Unexpected error: %s", err) 269 | } 270 | 271 | if len(pool.dead) != 0 { 272 | t.Errorf("Expected the dead list to be empty, got: %s", pool.dead) 273 | } 274 | }) 275 | } 276 | 277 | func TestStatusConnectionPoolResurrect(t *testing.T) { 278 | t.Run("Mark the connection as dead and add/remove it to the lists", func(t *testing.T) { 279 | pool := &statusConnectionPool{ 280 | live: []*Connection{}, 281 | dead: []*Connection{&Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}, IsDead: true}}, 282 | selector: &roundRobinSelector{curr: -1}, 283 | } 284 | 285 | conn := pool.dead[0] 286 | 287 | if err := pool.resurrect(conn, true); err != nil { 288 | t.Fatalf("Unexpected error: %s", err) 289 | } 290 | 291 | if conn.IsDead { 292 | t.Errorf("Expected connection to be dead, got: %s", conn) 293 | } 294 | 295 | if len(pool.dead) != 0 { 296 | t.Errorf("Expected no dead connections, got: %s", pool.dead) 297 | } 298 | 299 | if len(pool.live) != 1 { 300 | t.Errorf("Expected 1 live connection, got: %s", pool.live) 301 | } 302 | }) 303 | 304 | t.Run("Short circuit removal when the connection is not in the dead list", func(t *testing.T) { 305 | pool := &statusConnectionPool{ 306 | dead: []*Connection{&Connection{URL: &url.URL{Scheme: "http", Host: "bar"}, IsDead: true}}, 307 | selector: &roundRobinSelector{curr: -1}, 308 | } 309 | 310 | conn := &Connection{URL: &url.URL{Scheme: "http", Host: "foo1"}, IsDead: true} 311 | 312 | if err := pool.resurrect(conn, true); err != nil { 313 | t.Fatalf("Unexpected error: %s", err) 314 | } 315 | 316 | if len(pool.live) != 1 { 317 | t.Errorf("Expected 1 live connection, got: %s", pool.live) 318 | } 319 | 320 | if len(pool.dead) != 1 { 321 | t.Errorf("Expected 1 dead connection, got: %s", pool.dead) 322 | } 323 | }) 324 | 325 | t.Run("Schedule resurrect", func(t *testing.T) { 326 | defaultResurrectTimeoutInitial = 0 327 | defer func() { defaultResurrectTimeoutInitial = 60 * time.Second }() 328 | 329 | pool := &statusConnectionPool{ 330 | live: []*Connection{}, 331 | dead: []*Connection{ 332 | &Connection{ 333 | URL: &url.URL{Scheme: "http", Host: "foo1"}, 334 | Failures: 100, 335 | IsDead: true, 336 | DeadSince: time.Now().UTC(), 337 | }, 338 | }, 339 | selector: &roundRobinSelector{curr: -1}, 340 | } 341 | 342 | conn := pool.dead[0] 343 | pool.scheduleResurrect(conn) 344 | time.Sleep(50 * time.Millisecond) 345 | 346 | pool.Lock() 347 | defer pool.Unlock() 348 | 349 | if len(pool.live) != 1 { 350 | t.Errorf("Expected 1 live connection, got: %s", pool.live) 351 | } 352 | if len(pool.dead) != 0 { 353 | t.Errorf("Expected no dead connections, got: %s", pool.dead) 354 | } 355 | }) 356 | } 357 | 358 | func TestConnection(t *testing.T) { 359 | t.Run("String", func(t *testing.T) { 360 | conn := &Connection{ 361 | URL: &url.URL{Scheme: "http", Host: "foo1"}, 362 | Failures: 10, 363 | IsDead: true, 364 | DeadSince: time.Now().UTC(), 365 | } 366 | 367 | match, err := regexp.MatchString( 368 | ` dead=true failures=10`, 369 | conn.String(), 370 | ) 371 | 372 | if err != nil { 373 | t.Fatalf("Unexpected error: %s", err) 374 | } 375 | 376 | if !match { 377 | t.Errorf("Unexpected output: %s", conn) 378 | } 379 | }) 380 | } 381 | 382 | func TestUpdateConnectionPool(t *testing.T) { 383 | var initialConnections = []Connection{ 384 | {URL: &url.URL{Scheme: "http", Host: "foo1"}}, 385 | {URL: &url.URL{Scheme: "http", Host: "foo2"}}, 386 | {URL: &url.URL{Scheme: "http", Host: "foo3"}}, 387 | } 388 | 389 | initConnList := func() []*Connection { 390 | var conns []*Connection 391 | for i := 0; i < len(initialConnections); i++ { 392 | conns = append(conns, &initialConnections[i]) 393 | } 394 | 395 | return conns 396 | } 397 | 398 | t.Run("Update connection pool", func(t *testing.T) { 399 | pool := &statusConnectionPool{live: initConnList()} 400 | 401 | if len(pool.URLs()) != 3 { 402 | t.Fatalf("Invalid number of URLs: %d", len(pool.URLs())) 403 | } 404 | 405 | var updatedConnections = []*Connection{ 406 | {URL: &url.URL{Scheme: "http", Host: "foo1"}}, 407 | {URL: &url.URL{Scheme: "http", Host: "foo2"}}, 408 | {URL: &url.URL{Scheme: "http", Host: "bar1"}}, 409 | {URL: &url.URL{Scheme: "http", Host: "bar2"}}, 410 | } 411 | 412 | _ = pool.Update(updatedConnections) 413 | 414 | if len(pool.URLs()) != 4 { 415 | t.Fatalf("Invalid number of URLs: %d", len(pool.URLs())) 416 | } 417 | }) 418 | 419 | t.Run("Update connection removes unknown dead connections", func(t *testing.T) { 420 | // we start with a bar1 host which shouldn't be there 421 | pool := &statusConnectionPool{ 422 | live: initConnList(), 423 | dead: []*Connection{ 424 | {URL: &url.URL{Scheme: "http", Host: "bar1"}}, 425 | }, 426 | } 427 | 428 | pool.Update(initConnList()) 429 | if len(pool.dead) != 0 { 430 | t.Errorf("Expected no dead connections, got: %s", pool.dead) 431 | } 432 | }) 433 | 434 | t.Run("Update connection pool with dead connections", func(t *testing.T) { 435 | pool := &statusConnectionPool{live: initConnList()} 436 | 437 | pool.dead = []*Connection{ 438 | {URL: &url.URL{Scheme: "http", Host: "bar1"}}, 439 | } 440 | var updatedConnections = []*Connection{ 441 | {URL: &url.URL{Scheme: "http", Host: "foo1"}}, 442 | {URL: &url.URL{Scheme: "http", Host: "foo2"}}, 443 | {URL: &url.URL{Scheme: "http", Host: "bar1"}}, 444 | } 445 | 446 | pool.Update(updatedConnections) 447 | 448 | fmt.Println(pool.live) 449 | fmt.Println(pool.dead) 450 | }) 451 | 452 | t.Run("Update connection pool with different ports and or path", func(t *testing.T) { 453 | conns := []Connection{ 454 | {URL: &url.URL{Scheme: "http", Host: "foo1:9200"}}, 455 | {URL: &url.URL{Scheme: "http", Host: "foo1:9205"}}, 456 | {URL: &url.URL{Scheme: "http", Host: "foo1:9200", Path: "/bar1"}}, 457 | } 458 | pool := &statusConnectionPool{} 459 | for i := 0; i < len(conns); i++ { 460 | pool.live = append(pool.live, &conns[i]) 461 | } 462 | 463 | tmp := []*Connection{} 464 | for i := 0; i < len(conns); i++ { 465 | tmp = append(tmp, &conns[i]) 466 | } 467 | pool.Update(tmp) 468 | 469 | if len(pool.live) != len(tmp) { 470 | t.Errorf("Invalid number of connections: %d", len(pool.live)) 471 | } 472 | }) 473 | 474 | t.Run("Update connection pool lifecycle", func(t *testing.T) { 475 | // Set up a test connection pool with some initial connections 476 | cp := &statusConnectionPool{ 477 | live: initConnList(), 478 | } 479 | err := cp.Update(initConnList()) 480 | if err != nil { 481 | t.Errorf("Update() returned an error: %v", err) 482 | } 483 | 484 | // Test removing a connection that's no longer present 485 | connections := []*Connection{ 486 | {URL: &url.URL{Scheme: "http", Host: "foo1"}}, 487 | {URL: &url.URL{Scheme: "http", Host: "foo2"}}, 488 | } 489 | err = cp.Update(connections) 490 | if len(cp.live) != 2 { 491 | t.Errorf("Expected only two live connection after update") 492 | } 493 | 494 | // foo1 fails 495 | cp.OnFailure(cp.live[0]) 496 | // we update the connexion, nothing should move 497 | err = cp.Update(connections) 498 | if len(cp.live) != 1 { 499 | t.Errorf("Expected no connections to be added to lists") 500 | } 501 | 502 | // Test adding a new connection that's not already present 503 | connections = append(connections, &Connection{URL: &url.URL{Scheme: "http", Host: "foo12"}}) 504 | err = cp.Update(connections) 505 | if len(cp.live) != 2 { 506 | t.Errorf("Expected the new connection to be added to live list") 507 | } 508 | cp.resurrect(cp.dead[0], false) 509 | 510 | // Test updating with an empty list of connections 511 | connections = []*Connection{} 512 | err = cp.Update(connections) 513 | if len(cp.live) != 3 { 514 | t.Errorf("Expected connections to be untouched after empty update") 515 | } 516 | }) 517 | 518 | t.Run("Update connection pool with discovery", func(t *testing.T) { 519 | cp := &statusConnectionPool{ 520 | live: initConnList(), 521 | selector: &roundRobinSelector{curr: -1}, 522 | } 523 | 524 | connections := []*Connection{ 525 | {URL: &url.URL{Scheme: "http", Host: "foo2"}}, 526 | {URL: &url.URL{Scheme: "http", Host: "foo3"}}, 527 | } 528 | 529 | conn, err := cp.Next() 530 | if err != nil { 531 | t.Errorf("unexpected error: %s", err) 532 | } 533 | if conn.URL.Host != "foo1" { 534 | t.Errorf("Unexpected host: %s", conn.URL.Host) 535 | } 536 | 537 | // Update happens between Next and OnFailure 538 | cp.Update(connections) 539 | 540 | // conn fails, doesn't exist in live list anymore 541 | err = cp.OnFailure(conn) 542 | if err == nil { 543 | t.Errorf("OnFailure() returned an unexpected error") 544 | } 545 | 546 | if len(cp.dead) != 0 { 547 | t.Errorf("OnFailure() should not add unknown live connections to dead list") 548 | } 549 | }) 550 | } 551 | -------------------------------------------------------------------------------- /elastictransport/discovery.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "context" 22 | "encoding/json" 23 | "fmt" 24 | "io/ioutil" 25 | "net/http" 26 | "net/url" 27 | "sort" 28 | "strings" 29 | "sync" 30 | "time" 31 | ) 32 | 33 | // Discoverable defines the interface for transports supporting node discovery. 34 | type Discoverable interface { 35 | DiscoverNodes() error 36 | } 37 | 38 | // nodeInfo represents the information about node in a cluster. 39 | // 40 | // See: https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html 41 | type nodeInfo struct { 42 | ID string 43 | Name string 44 | URL *url.URL 45 | Roles []string `json:"roles"` 46 | Attributes map[string]interface{} 47 | HTTP struct { 48 | PublishAddress string `json:"publish_address"` 49 | } 50 | } 51 | 52 | // DiscoverNodes reloads the client connections by fetching information from the cluster. 53 | func (c *Client) DiscoverNodes() error { 54 | var conns []*Connection 55 | 56 | nodes, err := c.getNodesInfo() 57 | if err != nil { 58 | if debugLogger != nil { 59 | debugLogger.Logf("Error getting nodes info: %s\n", err) 60 | } 61 | return fmt.Errorf("discovery: get nodes: %s", err) 62 | } 63 | 64 | for _, node := range nodes { 65 | var ( 66 | isMasterOnlyNode bool 67 | ) 68 | 69 | roles := append(node.Roles[:0:0], node.Roles...) 70 | sort.Strings(roles) 71 | 72 | if len(roles) == 1 && roles[0] == "master" { 73 | isMasterOnlyNode = true 74 | } 75 | 76 | if debugLogger != nil { 77 | var skip string 78 | if isMasterOnlyNode { 79 | skip = "; [SKIP]" 80 | } 81 | debugLogger.Logf("Discovered node [%s]; %s; roles=%s%s\n", node.Name, node.URL, node.Roles, skip) 82 | } 83 | 84 | // Skip master only nodes 85 | // TODO(karmi): Move logic to Selector? 86 | if isMasterOnlyNode { 87 | continue 88 | } 89 | 90 | conns = append(conns, &Connection{ 91 | URL: node.URL, 92 | ID: node.ID, 93 | Name: node.Name, 94 | Roles: node.Roles, 95 | Attributes: node.Attributes, 96 | }) 97 | } 98 | 99 | c.Lock() 100 | defer c.Unlock() 101 | 102 | if lockable, ok := c.pool.(sync.Locker); ok { 103 | lockable.Lock() 104 | defer lockable.Unlock() 105 | } 106 | 107 | if c.poolFunc != nil { 108 | c.pool = c.poolFunc(conns, c.selector) 109 | } else { 110 | if p, ok := c.pool.(UpdatableConnectionPool); ok { 111 | err = p.Update(conns) 112 | if err != nil { 113 | if debugLogger != nil { 114 | debugLogger.Logf("Error updating pool: %s\n", err) 115 | } 116 | } 117 | } else { 118 | c.pool, err = NewConnectionPool(conns, c.selector) 119 | if err != nil { 120 | return err 121 | } 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (c *Client) getNodesInfo() ([]nodeInfo, error) { 129 | var ( 130 | out []nodeInfo 131 | scheme = c.urls[0].Scheme 132 | ) 133 | 134 | var ctx context.Context 135 | var cancel context.CancelFunc 136 | 137 | if c.discoverNodeTimeout != nil { 138 | ctx, cancel = context.WithTimeout(context.Background(), *c.discoverNodeTimeout) 139 | defer cancel() 140 | } else { 141 | ctx = context.Background() // Use default context if no timeout is set 142 | } 143 | 144 | req, err := http.NewRequestWithContext(ctx, "GET", "/_nodes/http", nil) 145 | if err != nil { 146 | return out, err 147 | } 148 | 149 | c.Lock() 150 | conn, err := c.pool.Next() 151 | c.Unlock() 152 | // TODO(karmi): If no connection is returned, fallback to original URLs 153 | if err != nil { 154 | return out, err 155 | } 156 | 157 | c.setReqURL(conn.URL, req) 158 | c.setReqAuth(conn.URL, req) 159 | c.setReqUserAgent(req) 160 | c.setReqGlobalHeader(req) 161 | 162 | res, err := c.transport.RoundTrip(req) 163 | if err != nil { 164 | return out, err 165 | } 166 | defer res.Body.Close() 167 | 168 | if res.StatusCode > 200 { 169 | body, _ := ioutil.ReadAll(res.Body) 170 | return out, fmt.Errorf("server error: %s: %s", res.Status, body) 171 | } 172 | 173 | var env map[string]json.RawMessage 174 | if err := json.NewDecoder(res.Body).Decode(&env); err != nil { 175 | return out, err 176 | } 177 | 178 | var nodes map[string]nodeInfo 179 | if err := json.Unmarshal(env["nodes"], &nodes); err != nil { 180 | return out, err 181 | } 182 | 183 | for id, node := range nodes { 184 | node.ID = id 185 | node.URL = c.getNodeURL(node, scheme) 186 | out = append(out, node) 187 | } 188 | 189 | return out, nil 190 | } 191 | 192 | func (c *Client) getNodeURL(node nodeInfo, scheme string) *url.URL { 193 | var ( 194 | host string 195 | port string 196 | 197 | addrs = strings.Split(node.HTTP.PublishAddress, "/") 198 | ports = strings.Split(node.HTTP.PublishAddress, ":") 199 | ) 200 | 201 | if len(addrs) > 1 { 202 | host = addrs[0] 203 | } else { 204 | host = strings.Split(addrs[0], ":")[0] 205 | } 206 | port = ports[len(ports)-1] 207 | 208 | u := &url.URL{ 209 | Scheme: scheme, 210 | Host: host + ":" + port, 211 | } 212 | 213 | return u 214 | } 215 | 216 | func (c *Client) scheduleDiscoverNodes(d time.Duration) { 217 | go c.DiscoverNodes() 218 | 219 | c.Lock() 220 | defer c.Unlock() 221 | if c.discoverNodesTimer != nil { 222 | c.discoverNodesTimer.Stop() 223 | } 224 | c.discoverNodesTimer = time.AfterFunc(c.discoverNodesInterval, func() { 225 | c.scheduleDiscoverNodes(c.discoverNodesInterval) 226 | }) 227 | } 228 | -------------------------------------------------------------------------------- /elastictransport/discovery_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "bytes" 25 | "crypto/tls" 26 | "encoding/json" 27 | "fmt" 28 | "io" 29 | "io/ioutil" 30 | "net/http" 31 | "net/url" 32 | "os" 33 | "reflect" 34 | "testing" 35 | "time" 36 | ) 37 | 38 | func TestDiscovery(t *testing.T) { 39 | defaultHandler := func(w http.ResponseWriter, r *http.Request) { 40 | f, err := os.Open("testdata/nodes.info.json") 41 | if err != nil { 42 | http.Error(w, fmt.Sprintf("Fixture error: %s", err), 500) 43 | return 44 | } 45 | io.Copy(w, f) 46 | } 47 | 48 | srv := &http.Server{Addr: "localhost:10001", Handler: http.HandlerFunc(defaultHandler)} 49 | srvTLS := &http.Server{Addr: "localhost:12001", Handler: http.HandlerFunc(defaultHandler)} 50 | 51 | go func() { 52 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 53 | t.Errorf("Unable to start server: %s", err) 54 | return 55 | } 56 | }() 57 | go func() { 58 | if err := srvTLS.ListenAndServeTLS("testdata/cert.pem", "testdata/key.pem"); err != nil && err != http.ErrServerClosed { 59 | t.Errorf("Unable to start server: %s", err) 60 | return 61 | } 62 | }() 63 | defer func() { srv.Close() }() 64 | defer func() { srvTLS.Close() }() 65 | 66 | time.Sleep(50 * time.Millisecond) 67 | 68 | t.Run("getNodesInfo()", func(t *testing.T) { 69 | u, _ := url.Parse("http://" + srv.Addr) 70 | tp, _ := New(Config{URLs: []*url.URL{u}}) 71 | 72 | nodes, err := tp.getNodesInfo() 73 | if err != nil { 74 | t.Fatalf("ERROR: %s", err) 75 | } 76 | fmt.Printf("NodesInfo: %+v\n", nodes) 77 | 78 | if len(nodes) != 3 { 79 | t.Errorf("Unexpected number of nodes, want=3, got=%d", len(nodes)) 80 | } 81 | 82 | for _, node := range nodes { 83 | switch node.Name { 84 | case "es1": 85 | if node.URL.String() != "http://127.0.0.1:10001" { 86 | t.Errorf("Unexpected URL: %s", node.URL.String()) 87 | } 88 | case "es2": 89 | if node.URL.String() != "http://localhost:10002" { 90 | t.Errorf("Unexpected URL: %s", node.URL.String()) 91 | } 92 | case "es3": 93 | if node.URL.String() != "http://127.0.0.1:10003" { 94 | t.Errorf("Unexpected URL: %s", node.URL.String()) 95 | } 96 | } 97 | } 98 | }) 99 | 100 | t.Run("DiscoverNodes()", func(t *testing.T) { 101 | u, _ := url.Parse("http://" + srv.Addr) 102 | tp, _ := New(Config{URLs: []*url.URL{u}}) 103 | 104 | tp.DiscoverNodes() 105 | 106 | pool, ok := tp.pool.(*statusConnectionPool) 107 | if !ok { 108 | t.Fatalf("Unexpected pool, want=statusConnectionPool, got=%T", tp.pool) 109 | } 110 | 111 | if len(pool.live) != 2 { 112 | t.Errorf("Unexpected number of nodes, want=2, got=%d", len(pool.live)) 113 | } 114 | 115 | for _, conn := range pool.live { 116 | switch conn.Name { 117 | case "es1": 118 | if conn.URL.String() != "http://127.0.0.1:10001" { 119 | t.Errorf("Unexpected URL: %s", conn.URL.String()) 120 | } 121 | case "es2": 122 | if conn.URL.String() != "http://localhost:10002" { 123 | t.Errorf("Unexpected URL: %s", conn.URL.String()) 124 | } 125 | default: 126 | t.Errorf("Unexpected node: %s", conn.Name) 127 | } 128 | } 129 | }) 130 | 131 | t.Run("DiscoverNodes() with SSL and authorization", func(t *testing.T) { 132 | u, _ := url.Parse("https://" + srvTLS.Addr) 133 | tp, _ := New(Config{ 134 | URLs: []*url.URL{u}, 135 | Username: "foo", 136 | Password: "bar", 137 | Transport: &http.Transport{ 138 | TLSClientConfig: &tls.Config{ 139 | InsecureSkipVerify: true, 140 | }, 141 | }, 142 | }) 143 | 144 | tp.DiscoverNodes() 145 | 146 | pool, ok := tp.pool.(*statusConnectionPool) 147 | if !ok { 148 | t.Fatalf("Unexpected pool, want=statusConnectionPool, got=%T", tp.pool) 149 | } 150 | 151 | if len(pool.live) != 2 { 152 | t.Errorf("Unexpected number of nodes, want=2, got=%d", len(pool.live)) 153 | } 154 | 155 | for _, conn := range pool.live { 156 | switch conn.Name { 157 | case "es1": 158 | if conn.URL.String() != "https://127.0.0.1:10001" { 159 | t.Errorf("Unexpected URL: %s", conn.URL.String()) 160 | } 161 | case "es2": 162 | if conn.URL.String() != "https://localhost:10002" { 163 | t.Errorf("Unexpected URL: %s", conn.URL.String()) 164 | } 165 | default: 166 | t.Errorf("Unexpected node: %s", conn.Name) 167 | } 168 | } 169 | }) 170 | 171 | t.Run("scheduleDiscoverNodes()", func(t *testing.T) { 172 | t.Skip("Skip") // TODO(karmi): Investigate the intermittent failures of this test 173 | 174 | var numURLs int 175 | u, _ := url.Parse("http://" + srv.Addr) 176 | 177 | tp, _ := New(Config{URLs: []*url.URL{u}, DiscoverNodesInterval: 10 * time.Millisecond}) 178 | 179 | tp.Lock() 180 | numURLs = len(tp.pool.URLs()) 181 | tp.Unlock() 182 | if numURLs != 1 { 183 | t.Errorf("Unexpected number of nodes, want=1, got=%d", numURLs) 184 | } 185 | 186 | time.Sleep(18 * time.Millisecond) // Wait until (*Client).scheduleDiscoverNodes() 187 | tp.Lock() 188 | numURLs = len(tp.pool.URLs()) 189 | tp.Unlock() 190 | if numURLs != 2 { 191 | t.Errorf("Unexpected number of nodes, want=2, got=%d", numURLs) 192 | } 193 | }) 194 | 195 | t.Run("Role based nodes discovery", func(t *testing.T) { 196 | type Node struct { 197 | URL string 198 | Roles []string 199 | } 200 | 201 | type fields struct { 202 | Nodes map[string]Node 203 | } 204 | type wants struct { 205 | wantErr bool 206 | wantsNConn int 207 | } 208 | tests := []struct { 209 | name string 210 | args fields 211 | want wants 212 | }{ 213 | { 214 | "Default roles should allow every node to be selected", 215 | fields{ 216 | Nodes: map[string]Node{ 217 | "es1": { 218 | URL: "es1:9200", 219 | Roles: []string{ 220 | "data", 221 | "data_cold", 222 | "data_content", 223 | "data_frozen", 224 | "data_hot", 225 | "data_warm", 226 | "ingest", 227 | "master", 228 | "ml", 229 | "remote_cluster_client", 230 | "transform", 231 | }, 232 | }, 233 | "es2": { 234 | URL: "es2:9200", 235 | Roles: []string{ 236 | "data", 237 | "data_cold", 238 | "data_content", 239 | "data_frozen", 240 | "data_hot", 241 | "data_warm", 242 | "ingest", 243 | "master", 244 | "ml", 245 | "remote_cluster_client", 246 | "transform", 247 | }, 248 | }, 249 | "es3": { 250 | URL: "es3:9200", 251 | Roles: []string{ 252 | "data", 253 | "data_cold", 254 | "data_content", 255 | "data_frozen", 256 | "data_hot", 257 | "data_warm", 258 | "ingest", 259 | "master", 260 | "ml", 261 | "remote_cluster_client", 262 | "transform", 263 | }, 264 | }, 265 | }, 266 | }, 267 | wants{ 268 | false, 3, 269 | }, 270 | }, 271 | { 272 | "Master only node should not be selected", 273 | fields{ 274 | Nodes: map[string]Node{ 275 | "es1": { 276 | URL: "es1:9200", 277 | Roles: []string{ 278 | "master", 279 | }, 280 | }, 281 | "es2": { 282 | URL: "es2:9200", 283 | Roles: []string{ 284 | "data", 285 | "data_cold", 286 | "data_content", 287 | "data_frozen", 288 | "data_hot", 289 | "data_warm", 290 | "ingest", 291 | "master", 292 | "ml", 293 | "remote_cluster_client", 294 | "transform", 295 | }, 296 | }, 297 | "es3": { 298 | URL: "es3:9200", 299 | Roles: []string{ 300 | "data", 301 | "data_cold", 302 | "data_content", 303 | "data_frozen", 304 | "data_hot", 305 | "data_warm", 306 | "ingest", 307 | "master", 308 | "ml", 309 | "remote_cluster_client", 310 | "transform", 311 | }, 312 | }, 313 | }, 314 | }, 315 | 316 | wants{ 317 | false, 2, 318 | }, 319 | }, 320 | { 321 | "Master and data only nodes should be selected", 322 | fields{ 323 | Nodes: map[string]Node{ 324 | "es1": { 325 | URL: "es1:9200", 326 | Roles: []string{ 327 | "data", 328 | "master", 329 | }, 330 | }, 331 | "es2": { 332 | URL: "es2:9200", 333 | Roles: []string{ 334 | "data", 335 | "master", 336 | }, 337 | }, 338 | }, 339 | }, 340 | 341 | wants{ 342 | false, 2, 343 | }, 344 | }, 345 | } 346 | for _, tt := range tests { 347 | t.Run(tt.name, func(t *testing.T) { 348 | type Http struct { 349 | PublishAddress string `json:"publish_address"` 350 | } 351 | type nodesInfo struct { 352 | Roles []string `json:"roles"` 353 | Http Http `json:"http"` 354 | } 355 | 356 | var names []string 357 | var urls []*url.URL 358 | for name, node := range tt.args.Nodes { 359 | u, _ := url.Parse(node.URL) 360 | urls = append(urls, u) 361 | names = append(names, name) 362 | } 363 | 364 | newRoundTripper := func() http.RoundTripper { 365 | return &mockTransp{ 366 | RoundTripFunc: func(req *http.Request) (*http.Response, error) { 367 | nodes := make(map[string]map[string]nodesInfo) 368 | nodes["nodes"] = make(map[string]nodesInfo) 369 | for name, node := range tt.args.Nodes { 370 | u, _ := url.Parse(node.URL) 371 | nodes["nodes"][name] = nodesInfo{ 372 | Roles: node.Roles, 373 | Http: Http{PublishAddress: u.String()}, 374 | } 375 | } 376 | 377 | b, _ := json.MarshalIndent(nodes, "", " ") 378 | 379 | return &http.Response{ 380 | Status: "200 OK", 381 | StatusCode: 200, 382 | ContentLength: int64(len(b)), 383 | Header: http.Header(map[string][]string{"Content-Type": {"application/json"}}), 384 | Body: ioutil.NopCloser(bytes.NewReader(b)), 385 | }, nil 386 | }, 387 | } 388 | } 389 | 390 | c, _ := New(Config{ 391 | URLs: urls, 392 | Transport: newRoundTripper(), 393 | }) 394 | c.DiscoverNodes() 395 | 396 | pool, ok := c.pool.(*statusConnectionPool) 397 | if !ok { 398 | t.Fatalf("Unexpected pool, want=statusConnectionPool, got=%T", c.pool) 399 | } 400 | 401 | if len(pool.live) != tt.want.wantsNConn { 402 | t.Errorf("Unexpected number of nodes, want=%d, got=%d", tt.want.wantsNConn, len(pool.live)) 403 | } 404 | 405 | for _, conn := range pool.live { 406 | if !reflect.DeepEqual(tt.args.Nodes[conn.ID].Roles, conn.Roles) { 407 | t.Errorf("Unexpected roles for node %s, want=%s, got=%s", conn.Name, tt.args.Nodes[conn.ID], conn.Roles) 408 | } 409 | } 410 | 411 | if err := c.DiscoverNodes(); (err != nil) != tt.want.wantErr { 412 | t.Errorf("DiscoverNodes() error = %v, wantErr %v", err, tt.want.wantErr) 413 | } 414 | }) 415 | } 416 | }) 417 | } 418 | -------------------------------------------------------------------------------- /elastictransport/doc.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | // 18 | 19 | /* 20 | Package elastictransport provides the transport layer for the Elastic clients. 21 | 22 | The default HTTP transport of the client is http.Transport; use the Transport option to customize it. 23 | 24 | The package will automatically retry requests on network-related errors, and on specific 25 | response status codes (by default 502, 503, 504). Use the RetryOnStatus option to customize the list. 26 | The transport will not retry a timeout network error, unless enabled by setting EnableRetryOnTimeout to true. 27 | 28 | Use the MaxRetries option to configure the number of retries, and set DisableRetry to true 29 | to disable the retry behaviour altogether. 30 | 31 | By default, the retry will be performed without any delay; to configure a backoff interval, 32 | implement the RetryBackoff option function; see an example in the package unit tests for information. 33 | 34 | When multiple addresses are passed in configuration, the package will use them in a round-robin fashion, 35 | and will keep track of live and dead nodes. The status of dead nodes is checked periodically. 36 | 37 | To customize the node selection behaviour, provide a Selector implementation in the configuration. 38 | To replace the connection pool entirely, provide a custom ConnectionPool implementation via 39 | the ConnectionPoolFunc option. 40 | 41 | The package defines the Logger interface for logging information about request and response. 42 | It comes with several bundled loggers for logging in text and JSON. 43 | 44 | Use the EnableDebugLogger option to enable the debugging logger for connection management. 45 | 46 | Use the EnableMetrics option to enable metric collection and export. 47 | */ 48 | package elastictransport 49 | -------------------------------------------------------------------------------- /elastictransport/elastictransport.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "bytes" 22 | "compress/gzip" 23 | "crypto/sha256" 24 | "crypto/tls" 25 | "crypto/x509" 26 | "encoding/hex" 27 | "errors" 28 | "fmt" 29 | "io" 30 | "io/ioutil" 31 | "net" 32 | "net/http" 33 | "net/url" 34 | "os" 35 | "strings" 36 | "sync" 37 | "time" 38 | ) 39 | 40 | const ( 41 | userAgentHeader = "User-Agent" 42 | ) 43 | 44 | var ( 45 | defaultMaxRetries = 3 46 | defaultRetryOnStatus = [...]int{502, 503, 504} 47 | ) 48 | 49 | // Interface defines the interface for HTTP client. 50 | type Interface interface { 51 | Perform(*http.Request) (*http.Response, error) 52 | } 53 | 54 | // Instrumented allows to retrieve the current transport Instrumentation 55 | type Instrumented interface { 56 | InstrumentationEnabled() Instrumentation 57 | } 58 | 59 | // Config represents the configuration of HTTP client. 60 | type Config struct { 61 | UserAgent string 62 | 63 | URLs []*url.URL 64 | Username string 65 | Password string 66 | APIKey string 67 | ServiceToken string 68 | 69 | Header http.Header 70 | CACert []byte 71 | 72 | // DisableRetry disables retrying requests. 73 | // 74 | // If DisableRetry is true, then RetryOnStatus, RetryOnError, MaxRetries, and RetryBackoff will be ignored. 75 | DisableRetry bool 76 | 77 | // RetryOnStatus holds an optional list of HTTP response status codes that should trigger a retry. 78 | // 79 | // If RetryOnStatus is nil, then the defaults will be used: 80 | // 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). 81 | RetryOnStatus []int 82 | 83 | // RetryOnError holds an optional function that will be called when a request fails due to an 84 | // HTTP transport error, to indicate whether the request should be retried, e.g. timeouts. 85 | RetryOnError func(*http.Request, error) bool 86 | MaxRetries int 87 | RetryBackoff func(attempt int) time.Duration 88 | 89 | CompressRequestBody bool 90 | CompressRequestBodyLevel int 91 | // If PoolCompressor is true, a sync.Pool based gzip writer is used. Should be enabled with CompressRequestBody. 92 | PoolCompressor bool 93 | 94 | EnableMetrics bool 95 | EnableDebugLogger bool 96 | 97 | Instrumentation Instrumentation 98 | 99 | DiscoverNodesInterval time.Duration 100 | DiscoverNodeTimeout *time.Duration 101 | 102 | Transport http.RoundTripper 103 | Logger Logger 104 | Selector Selector 105 | 106 | ConnectionPoolFunc func([]*Connection, Selector) ConnectionPool 107 | 108 | CertificateFingerprint string 109 | } 110 | 111 | // Client represents the HTTP client. 112 | type Client struct { 113 | sync.Mutex 114 | 115 | userAgent string 116 | 117 | urls []*url.URL 118 | username string 119 | password string 120 | apikey string 121 | servicetoken string 122 | fingerprint string 123 | header http.Header 124 | 125 | retryOnStatus []int 126 | disableRetry bool 127 | enableRetryOnTimeout bool 128 | 129 | maxRetries int 130 | retryOnError func(*http.Request, error) bool 131 | retryBackoff func(attempt int) time.Duration 132 | discoverNodesInterval time.Duration 133 | discoverNodesTimer *time.Timer 134 | discoverNodeTimeout *time.Duration 135 | 136 | compressRequestBody bool 137 | compressRequestBodyLevel int 138 | gzipCompressor gzipCompressor 139 | 140 | instrumentation Instrumentation 141 | 142 | metrics *metrics 143 | 144 | transport http.RoundTripper 145 | logger Logger 146 | selector Selector 147 | pool ConnectionPool 148 | poolFunc func([]*Connection, Selector) ConnectionPool 149 | } 150 | 151 | // New creates new transport client. 152 | // 153 | // http.DefaultTransport will be used if no transport is passed in the configuration. 154 | func New(cfg Config) (*Client, error) { 155 | if cfg.Transport == nil { 156 | defaultTransport, ok := http.DefaultTransport.(*http.Transport) 157 | if !ok { 158 | return nil, errors.New("cannot clone http.DefaultTransport") 159 | } 160 | cfg.Transport = defaultTransport.Clone() 161 | } 162 | 163 | if transport, ok := cfg.Transport.(*http.Transport); ok { 164 | if cfg.CertificateFingerprint != "" { 165 | transport.DialTLS = func(network, addr string) (net.Conn, error) { 166 | fingerprint, _ := hex.DecodeString(cfg.CertificateFingerprint) 167 | 168 | c, err := tls.Dial(network, addr, &tls.Config{InsecureSkipVerify: true}) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | // Retrieve the connection state from the remote server. 174 | cState := c.ConnectionState() 175 | for _, cert := range cState.PeerCertificates { 176 | // Compute digest for each certificate. 177 | digest := sha256.Sum256(cert.Raw) 178 | 179 | // Provided fingerprint should match at least one certificate from remote before we continue. 180 | if bytes.Compare(digest[0:], fingerprint) == 0 { 181 | return c, nil 182 | } 183 | } 184 | return nil, fmt.Errorf("fingerprint mismatch, provided: %s", cfg.CertificateFingerprint) 185 | } 186 | } 187 | } 188 | 189 | if cfg.CACert != nil { 190 | httpTransport, ok := cfg.Transport.(*http.Transport) 191 | if !ok { 192 | return nil, fmt.Errorf("unable to set CA certificate for transport of type %T", cfg.Transport) 193 | } 194 | 195 | httpTransport = httpTransport.Clone() 196 | httpTransport.TLSClientConfig.RootCAs = x509.NewCertPool() 197 | 198 | if ok := httpTransport.TLSClientConfig.RootCAs.AppendCertsFromPEM(cfg.CACert); !ok { 199 | return nil, errors.New("unable to add CA certificate") 200 | } 201 | 202 | cfg.Transport = httpTransport 203 | } 204 | 205 | if len(cfg.RetryOnStatus) == 0 { 206 | cfg.RetryOnStatus = defaultRetryOnStatus[:] 207 | } 208 | 209 | if cfg.MaxRetries == 0 { 210 | cfg.MaxRetries = defaultMaxRetries 211 | } 212 | 213 | var conns []*Connection 214 | for _, u := range cfg.URLs { 215 | conns = append(conns, &Connection{URL: u}) 216 | } 217 | 218 | client := Client{ 219 | userAgent: cfg.UserAgent, 220 | 221 | urls: cfg.URLs, 222 | username: cfg.Username, 223 | password: cfg.Password, 224 | apikey: cfg.APIKey, 225 | servicetoken: cfg.ServiceToken, 226 | header: cfg.Header, 227 | 228 | retryOnStatus: cfg.RetryOnStatus, 229 | disableRetry: cfg.DisableRetry, 230 | maxRetries: cfg.MaxRetries, 231 | retryOnError: cfg.RetryOnError, 232 | retryBackoff: cfg.RetryBackoff, 233 | discoverNodesInterval: cfg.DiscoverNodesInterval, 234 | 235 | compressRequestBody: cfg.CompressRequestBody, 236 | compressRequestBodyLevel: cfg.CompressRequestBodyLevel, 237 | 238 | transport: cfg.Transport, 239 | logger: cfg.Logger, 240 | selector: cfg.Selector, 241 | poolFunc: cfg.ConnectionPoolFunc, 242 | 243 | instrumentation: cfg.Instrumentation, 244 | } 245 | 246 | if cfg.DiscoverNodeTimeout != nil { 247 | client.discoverNodeTimeout = cfg.DiscoverNodeTimeout 248 | } 249 | 250 | if client.poolFunc != nil { 251 | client.pool = client.poolFunc(conns, client.selector) 252 | } else { 253 | client.pool, _ = NewConnectionPool(conns, client.selector) 254 | } 255 | 256 | if cfg.EnableDebugLogger { 257 | debugLogger = &debuggingLogger{Output: os.Stdout} 258 | } 259 | 260 | if cfg.EnableMetrics { 261 | client.metrics = &metrics{responses: make(map[int]int)} 262 | // TODO(karmi): Type assertion to interface 263 | if pool, ok := client.pool.(*singleConnectionPool); ok { 264 | pool.metrics = client.metrics 265 | } 266 | if pool, ok := client.pool.(*statusConnectionPool); ok { 267 | pool.metrics = client.metrics 268 | } 269 | } 270 | 271 | if client.discoverNodesInterval > 0 { 272 | time.AfterFunc(client.discoverNodesInterval, func() { 273 | client.scheduleDiscoverNodes(client.discoverNodesInterval) 274 | }) 275 | } 276 | 277 | if client.compressRequestBodyLevel == 0 { 278 | client.compressRequestBodyLevel = gzip.DefaultCompression 279 | } 280 | 281 | if cfg.PoolCompressor { 282 | client.gzipCompressor = newPooledGzipCompressor(client.compressRequestBodyLevel) 283 | } else { 284 | client.gzipCompressor = newSimpleGzipCompressor(client.compressRequestBodyLevel) 285 | } 286 | 287 | return &client, nil 288 | } 289 | 290 | // Perform executes the request and returns a response or error. 291 | func (c *Client) Perform(req *http.Request) (*http.Response, error) { 292 | var ( 293 | res *http.Response 294 | err error 295 | ) 296 | 297 | // Record metrics, when enabled 298 | if c.metrics != nil { 299 | c.metrics.Lock() 300 | c.metrics.requests++ 301 | c.metrics.Unlock() 302 | } 303 | 304 | // Update request 305 | c.setReqUserAgent(req) 306 | c.setReqGlobalHeader(req) 307 | 308 | if req.Body != nil && req.Body != http.NoBody { 309 | if c.compressRequestBody { 310 | buf, err := c.gzipCompressor.compress(req.Body) 311 | if err != nil { 312 | return nil, err 313 | } 314 | defer c.gzipCompressor.collectBuffer(buf) 315 | 316 | req.GetBody = func() (io.ReadCloser, error) { 317 | // Copy value of buf so it's not destroyed on first read 318 | r := *buf 319 | return ioutil.NopCloser(&r), nil 320 | } 321 | req.Body, _ = req.GetBody() 322 | 323 | req.Header.Set("Content-Encoding", "gzip") 324 | req.ContentLength = int64(buf.Len()) 325 | 326 | } else if req.GetBody == nil { 327 | if !c.disableRetry || (c.logger != nil && c.logger.RequestBodyEnabled()) { 328 | var buf bytes.Buffer 329 | buf.ReadFrom(req.Body) 330 | 331 | req.GetBody = func() (io.ReadCloser, error) { 332 | // Copy value of buf so it's not destroyed on first read 333 | r := buf 334 | return ioutil.NopCloser(&r), nil 335 | } 336 | req.Body, _ = req.GetBody() 337 | } 338 | } 339 | } 340 | 341 | originalPath := req.URL.Path 342 | for i := 0; i <= c.maxRetries; i++ { 343 | var ( 344 | conn *Connection 345 | shouldRetry bool 346 | shouldCloseBody bool 347 | ) 348 | 349 | // Get connection from the pool 350 | c.Lock() 351 | conn, err = c.pool.Next() 352 | c.Unlock() 353 | if err != nil { 354 | if c.logger != nil { 355 | c.logRoundTrip(req, nil, err, time.Time{}, time.Duration(0)) 356 | } 357 | return nil, fmt.Errorf("cannot get connection: %s", err) 358 | } 359 | 360 | // Update request 361 | c.setReqURL(conn.URL, req) 362 | c.setReqAuth(conn.URL, req) 363 | 364 | if !c.disableRetry && i > 0 && req.Body != nil && req.Body != http.NoBody { 365 | body, err := req.GetBody() 366 | if err != nil { 367 | return nil, fmt.Errorf("cannot get request body: %s", err) 368 | } 369 | req.Body = body 370 | } 371 | 372 | // Set up time measures and execute the request 373 | start := time.Now().UTC() 374 | res, err = c.transport.RoundTrip(req) 375 | dur := time.Since(start) 376 | 377 | // Log request and response 378 | if c.logger != nil { 379 | if c.logger.RequestBodyEnabled() && req.Body != nil && req.Body != http.NoBody { 380 | req.Body, _ = req.GetBody() 381 | } 382 | c.logRoundTrip(req, res, err, start, dur) 383 | } 384 | 385 | if err != nil { 386 | // Record metrics, when enabled 387 | if c.metrics != nil { 388 | c.metrics.Lock() 389 | c.metrics.failures++ 390 | c.metrics.Unlock() 391 | } 392 | 393 | // Report the connection as unsuccessful 394 | c.Lock() 395 | c.pool.OnFailure(conn) 396 | c.Unlock() 397 | 398 | // Retry upon decision by the user 399 | if !c.disableRetry && (c.retryOnError == nil || c.retryOnError(req, err)) { 400 | shouldRetry = true 401 | } 402 | } else { 403 | // Report the connection as succesfull 404 | c.Lock() 405 | c.pool.OnSuccess(conn) 406 | c.Unlock() 407 | } 408 | 409 | if res != nil && c.metrics != nil { 410 | c.metrics.Lock() 411 | c.metrics.responses[res.StatusCode]++ 412 | c.metrics.Unlock() 413 | } 414 | 415 | if res != nil && c.instrumentation != nil { 416 | c.instrumentation.AfterResponse(req.Context(), res) 417 | } 418 | 419 | // Retry on configured response statuses 420 | if res != nil && !c.disableRetry { 421 | for _, code := range c.retryOnStatus { 422 | if res.StatusCode == code { 423 | shouldRetry = true 424 | shouldCloseBody = true 425 | } 426 | } 427 | } 428 | 429 | // Break if retry should not be performed 430 | if !shouldRetry { 431 | break 432 | } 433 | 434 | // Drain and close body when retrying after response 435 | if shouldCloseBody && i < c.maxRetries { 436 | if res.Body != nil { 437 | io.Copy(ioutil.Discard, res.Body) 438 | res.Body.Close() 439 | } 440 | } 441 | 442 | // Delay the retry if a backoff function is configured 443 | if c.retryBackoff != nil { 444 | var cancelled bool 445 | backoff := c.retryBackoff(i + 1) 446 | timer := time.NewTimer(backoff) 447 | select { 448 | case <-req.Context().Done(): 449 | err = req.Context().Err() 450 | cancelled = true 451 | timer.Stop() 452 | case <-timer.C: 453 | } 454 | if cancelled { 455 | break 456 | } 457 | } 458 | 459 | // Re-init the path of the request to its original state 460 | // This will be re-enriched by the connection upon retry 461 | req.URL.Path = originalPath 462 | } 463 | 464 | // TODO(karmi): Wrap error 465 | return res, err 466 | } 467 | 468 | // URLs returns a list of transport URLs. 469 | func (c *Client) URLs() []*url.URL { 470 | return c.pool.URLs() 471 | } 472 | 473 | func (c *Client) InstrumentationEnabled() Instrumentation { 474 | return c.instrumentation 475 | } 476 | 477 | func (c *Client) setReqURL(u *url.URL, req *http.Request) *http.Request { 478 | req.URL.Scheme = u.Scheme 479 | req.URL.Host = u.Host 480 | 481 | if u.Path != "" { 482 | var b strings.Builder 483 | b.Grow(len(u.Path) + len(req.URL.Path)) 484 | b.WriteString(u.Path) 485 | b.WriteString(req.URL.Path) 486 | req.URL.Path = b.String() 487 | } 488 | 489 | return req 490 | } 491 | 492 | func (c *Client) setReqAuth(u *url.URL, req *http.Request) *http.Request { 493 | if _, ok := req.Header["Authorization"]; !ok { 494 | if u.User != nil { 495 | password, _ := u.User.Password() 496 | req.SetBasicAuth(u.User.Username(), password) 497 | return req 498 | } 499 | 500 | if c.apikey != "" { 501 | var b bytes.Buffer 502 | b.Grow(len("APIKey ") + len(c.apikey)) 503 | b.WriteString("APIKey ") 504 | b.WriteString(c.apikey) 505 | req.Header.Set("Authorization", b.String()) 506 | return req 507 | } 508 | 509 | if c.servicetoken != "" { 510 | var b bytes.Buffer 511 | b.Grow(len("Bearer ") + len(c.servicetoken)) 512 | b.WriteString("Bearer ") 513 | b.WriteString(c.servicetoken) 514 | req.Header.Set("Authorization", b.String()) 515 | return req 516 | } 517 | 518 | if c.username != "" && c.password != "" { 519 | req.SetBasicAuth(c.username, c.password) 520 | return req 521 | } 522 | } 523 | 524 | return req 525 | } 526 | 527 | func (c *Client) setReqUserAgent(req *http.Request) *http.Request { 528 | if len(c.header) > 0 { 529 | ua := c.header.Get(userAgentHeader) 530 | if ua != "" { 531 | req.Header.Set(userAgentHeader, ua) 532 | return req 533 | } 534 | } 535 | req.Header.Set(userAgentHeader, c.userAgent) 536 | return req 537 | } 538 | 539 | func (c *Client) setReqGlobalHeader(req *http.Request) *http.Request { 540 | if len(c.header) > 0 { 541 | for k, v := range c.header { 542 | if req.Header.Get(k) != k { 543 | for _, vv := range v { 544 | req.Header.Add(k, vv) 545 | } 546 | } 547 | } 548 | } 549 | return req 550 | } 551 | 552 | func (c *Client) logRoundTrip( 553 | req *http.Request, 554 | res *http.Response, 555 | err error, 556 | start time.Time, 557 | dur time.Duration, 558 | ) { 559 | var dupRes http.Response 560 | if res != nil { 561 | dupRes = *res 562 | } 563 | if c.logger.ResponseBodyEnabled() { 564 | if res != nil && res.Body != nil && res.Body != http.NoBody { 565 | b1, b2, _ := duplicateBody(res.Body) 566 | dupRes.Body = b1 567 | res.Body = b2 568 | } 569 | } 570 | c.logger.LogRoundTrip(req, &dupRes, err, start, dur) // errcheck exclude 571 | } 572 | -------------------------------------------------------------------------------- /elastictransport/elastictransport_benchmark_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport_test 22 | 23 | import ( 24 | "io/ioutil" 25 | "net/http" 26 | "net/url" 27 | "strings" 28 | "testing" 29 | 30 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 31 | ) 32 | 33 | var defaultResponse = http.Response{ 34 | Status: "200 OK", 35 | StatusCode: 200, 36 | ContentLength: 13, 37 | Header: http.Header(map[string][]string{"Content-Type": {"application/json"}}), 38 | Body: ioutil.NopCloser(strings.NewReader(`{"foo":"bar"}`)), 39 | } 40 | 41 | type FakeTransport struct { 42 | FakeResponse *http.Response 43 | } 44 | 45 | func (t *FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { 46 | return t.FakeResponse, nil 47 | } 48 | 49 | func newFakeTransport(b *testing.B) *FakeTransport { 50 | return &FakeTransport{FakeResponse: &defaultResponse} 51 | } 52 | 53 | func BenchmarkTransport(b *testing.B) { 54 | b.ReportAllocs() 55 | 56 | b.Run("Defaults", func(b *testing.B) { 57 | for i := 0; i < b.N; i++ { 58 | tp, _ := elastictransport.New(elastictransport.Config{ 59 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 60 | Transport: newFakeTransport(b), 61 | }) 62 | 63 | req, _ := http.NewRequest("GET", "/abc", nil) 64 | _, err := tp.Perform(req) 65 | if err != nil { 66 | b.Fatalf("Unexpected error: %s", err) 67 | } 68 | } 69 | }) 70 | 71 | b.Run("Headers", func(b *testing.B) { 72 | hdr := http.Header{} 73 | hdr.Set("Accept", "application/yaml") 74 | 75 | for i := 0; i < b.N; i++ { 76 | tp, _ := elastictransport.New(elastictransport.Config{ 77 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 78 | Header: hdr, 79 | Transport: newFakeTransport(b), 80 | }) 81 | 82 | req, _ := http.NewRequest("GET", "/abc", nil) 83 | _, err := tp.Perform(req) 84 | if err != nil { 85 | b.Fatalf("Unexpected error: %s", err) 86 | } 87 | } 88 | }) 89 | 90 | b.Run("Compress body (pool: false)", func(b *testing.B) { 91 | tp, _ := elastictransport.New(elastictransport.Config{ 92 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 93 | Transport: newFakeTransport(b), 94 | CompressRequestBody: true, 95 | }) 96 | 97 | for i := 0; i < b.N; i++ { 98 | body := strings.NewReader(`{"query":{"match_all":{}}}`) 99 | 100 | req, _ := http.NewRequest("GET", "/abc", body) 101 | _, err := tp.Perform(req) 102 | if err != nil { 103 | b.Fatalf("Unexpected error: %s", err) 104 | } 105 | } 106 | }) 107 | 108 | b.Run("Compress body (pool: true)", func(b *testing.B) { 109 | tp, _ := elastictransport.New(elastictransport.Config{ 110 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 111 | Transport: newFakeTransport(b), 112 | CompressRequestBody: true, 113 | PoolCompressor: true, 114 | }) 115 | 116 | for i := 0; i < b.N; i++ { 117 | body := strings.NewReader(`{"query":{"match_all":{}}}`) 118 | 119 | req, _ := http.NewRequest("GET", "/abc", body) 120 | _, err := tp.Perform(req) 121 | if err != nil { 122 | b.Fatalf("Unexpected error: %s", err) 123 | } 124 | } 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /elastictransport/elastictransport_integration_multinode_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build integration && multinode 19 | // +build integration,multinode 20 | 21 | package elastictransport_test 22 | 23 | import ( 24 | "encoding/json" 25 | "fmt" 26 | "net/http" 27 | "net/url" 28 | "testing" 29 | 30 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 31 | ) 32 | 33 | var ( 34 | _ = fmt.Print 35 | ) 36 | 37 | func TestTransportSelector(t *testing.T) { 38 | NodeName := func(t *testing.T, transport elastictransport.Interface) string { 39 | req, err := http.NewRequest("GET", "/", nil) 40 | if err != nil { 41 | t.Fatalf("Unexpected error: %s", err) 42 | } 43 | 44 | res, err := transport.Perform(req) 45 | if err != nil { 46 | t.Fatalf("Unexpected error: %s", err) 47 | } 48 | 49 | fmt.Printf("> GET %s\n", req.URL) 50 | 51 | r := struct { 52 | Name string 53 | }{} 54 | 55 | if err := json.NewDecoder(res.Body).Decode(&r); err != nil { 56 | t.Fatalf("Error parsing the response body: %s", err) 57 | } 58 | 59 | return r.Name 60 | } 61 | 62 | t.Run("RoundRobin", func(t *testing.T) { 63 | var ( 64 | node string 65 | ) 66 | transport, _ := elastictransport.New(elastictransport.Config{URLs: []*url.URL{ 67 | {Scheme: "http", Host: "localhost:9200"}, 68 | {Scheme: "http", Host: "localhost:9201"}, 69 | }}) 70 | 71 | node = NodeName(t, transport) 72 | if node != "es1" { 73 | t.Errorf("Unexpected node, want=e1, got=%s", node) 74 | } 75 | 76 | node = NodeName(t, transport) 77 | if node != "es2" { 78 | t.Errorf("Unexpected node, want=e1, got=%s", node) 79 | } 80 | 81 | node = NodeName(t, transport) 82 | if node != "es1" { 83 | t.Errorf("Unexpected node, want=e1, got=%s", node) 84 | } 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /elastictransport/elastictransport_integration_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build integration 19 | // +build integration 20 | 21 | package elastictransport_test 22 | 23 | import ( 24 | "bytes" 25 | "fmt" 26 | "io" 27 | "io/ioutil" 28 | "net/http" 29 | "net/http/httptest" 30 | "net/url" 31 | "strings" 32 | "testing" 33 | "testing/iotest" 34 | 35 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 36 | ) 37 | 38 | var ( 39 | _ = fmt.Print 40 | ) 41 | 42 | func TestTransportRetries(t *testing.T) { 43 | var counter int 44 | 45 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | counter++ 47 | 48 | body, _ := ioutil.ReadAll(r.Body) 49 | fmt.Println("req.Body:", string(body)) 50 | 51 | http.Error(w, "FAKE 502", http.StatusBadGateway) 52 | })) 53 | serverURL, _ := url.Parse(server.URL) 54 | 55 | transport, _ := elastictransport.New(elastictransport.Config{URLs: []*url.URL{serverURL}}) 56 | 57 | bodies := []io.Reader{ 58 | strings.NewReader(`FAKE`), 59 | strings.NewReader(`FAKE`), 60 | } 61 | 62 | for _, body := range bodies { 63 | t.Run(fmt.Sprintf("Reset the %T request body", body), func(t *testing.T) { 64 | counter = 0 65 | 66 | req, err := http.NewRequest("GET", "/", body) 67 | if err != nil { 68 | t.Fatalf("Unexpected error: %s", err) 69 | } 70 | 71 | res, err := transport.Perform(req) 72 | if err != nil { 73 | t.Fatalf("Unexpected error: %s", err) 74 | } 75 | 76 | if body, _ := req.GetBody(); body == nil || isEmptyReader(body) { 77 | t.Fatal("request body should not be consumed by transport.Perform") 78 | } 79 | 80 | body, _ := ioutil.ReadAll(res.Body) 81 | 82 | fmt.Println("> GET", req.URL) 83 | fmt.Printf("< %s (tries: %d)\n", bytes.TrimSpace(body), counter) 84 | 85 | if counter != 4 { 86 | t.Errorf("Unexpected number of attempts, want=4, got=%d", counter) 87 | } 88 | }) 89 | } 90 | } 91 | 92 | func TestTransportRetriesWithCompression(t *testing.T) { 93 | var counter int 94 | 95 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 96 | counter++ 97 | 98 | body, _ := ioutil.ReadAll(r.Body) 99 | fmt.Println("req.Body:", string(body)) 100 | 101 | http.Error(w, "FAKE 502", http.StatusBadGateway) 102 | })) 103 | serverURL, _ := url.Parse(server.URL) 104 | 105 | transport, _ := elastictransport.New(elastictransport.Config{ 106 | URLs: []*url.URL{ 107 | serverURL, 108 | }, 109 | CompressRequestBody: true, 110 | }) 111 | 112 | bodies := []io.Reader{ 113 | strings.NewReader(`FAKE`), 114 | strings.NewReader(`FAKE`), 115 | } 116 | 117 | for _, body := range bodies { 118 | t.Run(fmt.Sprintf("Reset the %T request body", body), func(t *testing.T) { 119 | counter = 0 120 | 121 | req, err := http.NewRequest("GET", "/", body) 122 | if err != nil { 123 | t.Fatalf("Unexpected error: %s", err) 124 | } 125 | 126 | res, err := transport.Perform(req) 127 | if err != nil { 128 | t.Fatalf("Unexpected error: %s", err) 129 | } 130 | 131 | if body, _ := req.GetBody(); body == nil || isEmptyReader(body) { 132 | t.Fatal("request body should not be consumed by transport.Perform") 133 | } 134 | 135 | body, _ := ioutil.ReadAll(res.Body) 136 | 137 | fmt.Println("> GET", req.URL) 138 | fmt.Printf("< %s (tries: %d)\n", bytes.TrimSpace(body), counter) 139 | 140 | if counter != 4 { 141 | t.Errorf("Unexpected number of attempts, want=4, got=%d", counter) 142 | } 143 | }) 144 | } 145 | } 146 | 147 | func TestTransportHeaders(t *testing.T) { 148 | u, _ := url.Parse("http://localhost:9200") 149 | 150 | hdr := http.Header{} 151 | hdr.Set("Accept", "application/yaml") 152 | 153 | tp, _ := elastictransport.New(elastictransport.Config{ 154 | URLs: []*url.URL{u}, 155 | Header: hdr, 156 | }) 157 | 158 | req, _ := http.NewRequest("GET", "/", nil) 159 | res, err := tp.Perform(req) 160 | if err != nil { 161 | t.Fatalf("Unexpected error: %s", err) 162 | } 163 | defer res.Body.Close() 164 | 165 | body, err := ioutil.ReadAll(res.Body) 166 | if err != nil { 167 | t.Fatalf("Unexpected error: %s", err) 168 | } 169 | 170 | if !bytes.HasPrefix(body, []byte("---")) { 171 | t.Errorf("Unexpected response body:\n%s", body) 172 | } 173 | } 174 | 175 | func TestTransportCompression(t *testing.T) { 176 | var req *http.Request 177 | var res *http.Response 178 | var err error 179 | var u *url.URL 180 | 181 | u, _ = url.Parse("http://localhost:9200") 182 | 183 | transport, _ := elastictransport.New(elastictransport.Config{ 184 | URLs: []*url.URL{u}, 185 | CompressRequestBody: true, 186 | }) 187 | 188 | indexName := "/shiny_new_index" 189 | 190 | req, _ = http.NewRequest(http.MethodPut, indexName, nil) 191 | res, err = transport.Perform(req) 192 | if err != nil { 193 | t.Fatalf("Unexpected error, cannot create index: %v", err) 194 | } 195 | 196 | req, _ = http.NewRequest(http.MethodGet, indexName, nil) 197 | res, err = transport.Perform(req) 198 | if err != nil { 199 | t.Fatalf("Unexpected error, cannot find index: %v", err) 200 | } 201 | 202 | req, _ = http.NewRequest( 203 | http.MethodPost, 204 | strings.Join([]string{indexName, "/_doc"}, ""), 205 | strings.NewReader(`{"solidPayload": 1}`), 206 | ) 207 | req.Header.Set("Content-Type", "application/json") 208 | res, err = transport.Perform(req) 209 | if err != nil { 210 | t.Fatalf("Unexpected error, cannot POST payload: %v", err) 211 | } 212 | 213 | if body, _ := req.GetBody(); body == nil || isEmptyReader(body) { 214 | t.Fatal("request body should not be consumed by transport.Perform") 215 | } 216 | 217 | if res.StatusCode != http.StatusCreated { 218 | t.Fatalf("Unexpected StatusCode, expected 201, got: %v", res.StatusCode) 219 | } 220 | 221 | req, _ = http.NewRequest(http.MethodDelete, indexName, nil) 222 | _, err = transport.Perform(req) 223 | if err != nil { 224 | t.Fatalf("Unexpected error, cannot DELETE %s: %v", indexName, err) 225 | } 226 | } 227 | 228 | func isEmptyReader(r io.Reader) bool { 229 | _, err := iotest.OneByteReader(r).Read(make([]byte, 1)) 230 | return err == io.EOF 231 | } 232 | -------------------------------------------------------------------------------- /elastictransport/gzip.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "bytes" 22 | "compress/gzip" 23 | "fmt" 24 | "io" 25 | "sync" 26 | ) 27 | 28 | type gzipCompressor interface { 29 | // compress compresses the given io.ReadCloser and returns the gzip compressed data as a bytes.Buffer. 30 | compress(io.ReadCloser) (*bytes.Buffer, error) 31 | // collectBuffer collects the given bytes.Buffer for reuse. 32 | collectBuffer(*bytes.Buffer) 33 | } 34 | 35 | // simpleGzipCompressor is a simple implementation of gzipCompressor that creates a new gzip.Writer for each call. 36 | type simpleGzipCompressor struct { 37 | compressionLevel int 38 | } 39 | 40 | func newSimpleGzipCompressor(compressionLevel int) gzipCompressor { 41 | return &simpleGzipCompressor{ 42 | compressionLevel: compressionLevel, 43 | } 44 | } 45 | 46 | func (sg *simpleGzipCompressor) compress(rc io.ReadCloser) (*bytes.Buffer, error) { 47 | var buf bytes.Buffer 48 | zw, err := gzip.NewWriterLevel(&buf, sg.compressionLevel) 49 | if err != nil { 50 | return nil, fmt.Errorf("failed setting up up compress request body (level %d): %s", 51 | sg.compressionLevel, err) 52 | } 53 | 54 | if _, err = io.Copy(zw, rc); err != nil { 55 | return nil, fmt.Errorf("failed to compress request body: %s", err) 56 | } 57 | if err := zw.Close(); err != nil { 58 | return nil, fmt.Errorf("failed to compress request body (during close): %s", err) 59 | } 60 | return &buf, nil 61 | } 62 | 63 | func (sg *simpleGzipCompressor) collectBuffer(buf *bytes.Buffer) { 64 | // no-op 65 | } 66 | 67 | type pooledGzipCompressor struct { 68 | gzipWriterPool *sync.Pool 69 | bufferPool *sync.Pool 70 | compressionLevel int 71 | } 72 | 73 | type gzipWriter struct { 74 | writer *gzip.Writer 75 | err error 76 | } 77 | 78 | // newPooledGzipCompressor returns a new pooledGzipCompressor that uses a sync.Pool to reuse gzip.Writers. 79 | func newPooledGzipCompressor(compressionLevel int) gzipCompressor { 80 | gzipWriterPool := sync.Pool{ 81 | New: func() any { 82 | writer, err := gzip.NewWriterLevel(io.Discard, compressionLevel) 83 | return &gzipWriter{ 84 | writer: writer, 85 | err: err, 86 | } 87 | }, 88 | } 89 | 90 | bufferPool := sync.Pool{ 91 | New: func() any { 92 | return new(bytes.Buffer) 93 | }, 94 | } 95 | 96 | return &pooledGzipCompressor{ 97 | gzipWriterPool: &gzipWriterPool, 98 | bufferPool: &bufferPool, 99 | compressionLevel: compressionLevel, 100 | } 101 | } 102 | 103 | func (pg *pooledGzipCompressor) compress(rc io.ReadCloser) (*bytes.Buffer, error) { 104 | writer := pg.gzipWriterPool.Get().(*gzipWriter) 105 | defer pg.gzipWriterPool.Put(writer) 106 | 107 | if writer.err != nil { 108 | return nil, fmt.Errorf("failed setting up up compress request body (level %d): %s", 109 | pg.compressionLevel, writer.err) 110 | } 111 | 112 | buf := pg.bufferPool.Get().(*bytes.Buffer) 113 | buf.Reset() 114 | writer.writer.Reset(buf) 115 | 116 | if _, err := io.Copy(writer.writer, rc); err != nil { 117 | return nil, fmt.Errorf("failed to compress request body: %s", err) 118 | } 119 | if err := writer.writer.Close(); err != nil { 120 | return nil, fmt.Errorf("failed to compress request body (during close): %s", err) 121 | } 122 | return buf, nil 123 | } 124 | 125 | func (pg *pooledGzipCompressor) collectBuffer(buf *bytes.Buffer) { 126 | pg.bufferPool.Put(buf) 127 | } 128 | -------------------------------------------------------------------------------- /elastictransport/instrumentation.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "bytes" 22 | "context" 23 | "go.opentelemetry.io/otel" 24 | "go.opentelemetry.io/otel/attribute" 25 | "go.opentelemetry.io/otel/codes" 26 | "go.opentelemetry.io/otel/trace" 27 | "io" 28 | "net/http" 29 | "strconv" 30 | ) 31 | 32 | const schemaUrl = "https://opentelemetry.io/schemas/1.21.0" 33 | const tracerName = "elasticsearch-api" 34 | 35 | // Constants for Semantic Convention 36 | // see https://opentelemetry.io/docs/specs/semconv/database/elasticsearch/ for details. 37 | const attrDbSystem = "db.system" 38 | const attrDbStatement = "db.statement" 39 | const attrDbOperation = "db.operation" 40 | const attrDbElasticsearchClusterName = "db.elasticsearch.cluster.name" 41 | const attrDbElasticsearchNodeName = "db.elasticsearch.node.name" 42 | const attrHttpRequestMethod = "http.request.method" 43 | const attrUrlFull = "url.full" 44 | const attrServerAddress = "server.address" 45 | const attrServerPort = "server.port" 46 | const attrPathParts = "db.elasticsearch.path_parts." 47 | 48 | // Instrumentation defines the interface the client uses to propagate information about the requests. 49 | // Each method is called with the current context or request for propagation. 50 | type Instrumentation interface { 51 | // Start creates the span before building the request, returned context will be propagated to the request by the client. 52 | Start(ctx context.Context, name string) context.Context 53 | 54 | // Close will be called once the client has returned. 55 | Close(ctx context.Context) 56 | 57 | // RecordError propagates an error. 58 | RecordError(ctx context.Context, err error) 59 | 60 | // RecordPathPart provides the path variables, called once per variable in the url. 61 | RecordPathPart(ctx context.Context, pathPart, value string) 62 | 63 | // RecordRequestBody provides the endpoint name as well as the current request payload. 64 | RecordRequestBody(ctx context.Context, endpoint string, query io.Reader) io.ReadCloser 65 | 66 | // BeforeRequest provides the request and endpoint name, called before sending to the server. 67 | BeforeRequest(req *http.Request, endpoint string) 68 | 69 | // AfterRequest provides the request, system used (e.g. elasticsearch) and endpoint name. 70 | // Called after the request has been enhanced with the information from the transport and sent to the server. 71 | AfterRequest(req *http.Request, system, endpoint string) 72 | 73 | // AfterResponse provides the response. 74 | AfterResponse(ctx context.Context, res *http.Response) 75 | } 76 | 77 | type ElasticsearchOpenTelemetry struct { 78 | tracer trace.Tracer 79 | recordBody bool 80 | } 81 | 82 | // NewOtelInstrumentation returns a new instrument for Open Telemetry traces 83 | // If no provider is passed, the instrumentation will fall back to the global otel provider. 84 | // captureSearchBody sets the query capture behavior for search endpoints. 85 | // version should be set to the version provided by the caller. 86 | func NewOtelInstrumentation(provider trace.TracerProvider, captureSearchBody bool, version string, options ...trace.TracerOption) *ElasticsearchOpenTelemetry { 87 | if provider == nil { 88 | provider = otel.GetTracerProvider() 89 | } 90 | 91 | options = append(options, trace.WithInstrumentationVersion(version), trace.WithSchemaURL(schemaUrl)) 92 | 93 | return &ElasticsearchOpenTelemetry{ 94 | tracer: provider.Tracer( 95 | tracerName, 96 | options..., 97 | ), 98 | recordBody: captureSearchBody, 99 | } 100 | } 101 | 102 | // Start begins a new span in the given context with the provided name. 103 | // Span will always have a kind set to trace.SpanKindClient. 104 | // The context span aware is returned for use within the client. 105 | func (i ElasticsearchOpenTelemetry) Start(ctx context.Context, name string) context.Context { 106 | newCtx, _ := i.tracer.Start(ctx, name, trace.WithSpanKind(trace.SpanKindClient)) 107 | return newCtx 108 | } 109 | 110 | // Close call for the end of the span, preferably defered by the client once started. 111 | func (i ElasticsearchOpenTelemetry) Close(ctx context.Context) { 112 | span := trace.SpanFromContext(ctx) 113 | if span.IsRecording() { 114 | span.End() 115 | } 116 | } 117 | 118 | // shouldRecordRequestBody filters for search endpoints. 119 | func (i ElasticsearchOpenTelemetry) shouldRecordRequestBody(endpoint string) bool { 120 | // allow list of endpoints that will propagate query to OpenTelemetry. 121 | // see https://opentelemetry.io/docs/specs/semconv/database/elasticsearch/#call-level-attributes 122 | var searchEndpoints = map[string]struct{}{ 123 | "search": {}, 124 | "async_search.submit": {}, 125 | "msearch": {}, 126 | "eql.search": {}, 127 | "terms_enum": {}, 128 | "search_template": {}, 129 | "msearch_template": {}, 130 | "render_search_template": {}, 131 | } 132 | 133 | if i.recordBody { 134 | if _, ok := searchEndpoints[endpoint]; ok { 135 | return true 136 | } 137 | } 138 | return false 139 | } 140 | 141 | // RecordRequestBody add the db.statement attributes only for search endpoints. 142 | // Returns a new reader if the query has been recorded, nil otherwise. 143 | func (i ElasticsearchOpenTelemetry) RecordRequestBody(ctx context.Context, endpoint string, query io.Reader) io.ReadCloser { 144 | if i.shouldRecordRequestBody(endpoint) == false { 145 | return nil 146 | } 147 | 148 | span := trace.SpanFromContext(ctx) 149 | if span.IsRecording() { 150 | buf := bytes.Buffer{} 151 | buf.ReadFrom(query) 152 | span.SetAttributes(attribute.String(attrDbStatement, buf.String())) 153 | getBody := func() (io.ReadCloser, error) { 154 | reader := buf 155 | return io.NopCloser(&reader), nil 156 | } 157 | reader, _ := getBody() 158 | return reader 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // RecordError sets any provided error as an OTel error in the active span. 165 | func (i ElasticsearchOpenTelemetry) RecordError(ctx context.Context, err error) { 166 | span := trace.SpanFromContext(ctx) 167 | if span.IsRecording() { 168 | span.SetStatus(codes.Error, "an error happened while executing a request") 169 | span.RecordError(err) 170 | } 171 | } 172 | 173 | // RecordPathPart sets the couple for a specific path part. 174 | // An index placed in the path would translate to `db.elasticsearch.path_parts.index`. 175 | func (i ElasticsearchOpenTelemetry) RecordPathPart(ctx context.Context, pathPart, value string) { 176 | span := trace.SpanFromContext(ctx) 177 | if span.IsRecording() { 178 | span.SetAttributes(attribute.String(attrPathParts+pathPart, value)) 179 | } 180 | } 181 | 182 | // BeforeRequest noop for interface. 183 | func (i ElasticsearchOpenTelemetry) BeforeRequest(req *http.Request, endpoint string) {} 184 | 185 | // AfterRequest enrich the span with the available data from the request. 186 | func (i ElasticsearchOpenTelemetry) AfterRequest(req *http.Request, system, endpoint string) { 187 | span := trace.SpanFromContext(req.Context()) 188 | if span.IsRecording() { 189 | span.SetAttributes( 190 | attribute.String(attrDbSystem, system), 191 | attribute.String(attrDbOperation, endpoint), 192 | attribute.String(attrHttpRequestMethod, req.Method), 193 | attribute.String(attrUrlFull, req.URL.String()), 194 | attribute.String(attrServerAddress, req.URL.Hostname()), 195 | ) 196 | if value, err := strconv.ParseInt(req.URL.Port(), 10, 32); err == nil { 197 | span.SetAttributes(attribute.Int64(attrServerPort, value)) 198 | } 199 | } 200 | } 201 | 202 | // AfterResponse enric the span with the cluster id and node name if the query was executed on Elastic Cloud. 203 | func (i ElasticsearchOpenTelemetry) AfterResponse(ctx context.Context, res *http.Response) { 204 | span := trace.SpanFromContext(ctx) 205 | if span.IsRecording() { 206 | if id := res.Header.Get("X-Found-Handling-Cluster"); id != "" { 207 | span.SetAttributes( 208 | attribute.String(attrDbElasticsearchClusterName, id), 209 | ) 210 | } 211 | if name := res.Header.Get("X-Found-Handling-Instance"); name != "" { 212 | span.SetAttributes( 213 | attribute.String(attrDbElasticsearchNodeName, name), 214 | ) 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /elastictransport/instrumentation_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | "go.opentelemetry.io/otel/codes" 24 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 25 | "go.opentelemetry.io/otel/sdk/trace/tracetest" 26 | "go.opentelemetry.io/otel/trace" 27 | "net/http" 28 | "reflect" 29 | "strings" 30 | "testing" 31 | ) 32 | 33 | var spanName = "search" 34 | var endpoint = spanName 35 | 36 | func NewTestOpenTelemetry() (*tracetest.InMemoryExporter, *sdktrace.TracerProvider, *ElasticsearchOpenTelemetry) { 37 | exporter := tracetest.NewInMemoryExporter() 38 | provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) 39 | instrumentation := NewOtelInstrumentation(provider, true, "8.99.0-SNAPSHOT") 40 | return exporter, provider, instrumentation 41 | } 42 | 43 | func TestElasticsearchOpenTelemetry_StartClose(t *testing.T) { 44 | t.Run("Valid Start name", func(t *testing.T) { 45 | exporter, provider, instrument := NewTestOpenTelemetry() 46 | 47 | ctx := instrument.Start(context.Background(), spanName) 48 | instrument.Close(ctx) 49 | err := provider.ForceFlush(context.Background()) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | if ctx == nil { 55 | t.Fatalf("Start() returned an empty context") 56 | } 57 | 58 | if len(exporter.GetSpans()) != 1 { 59 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 60 | } 61 | 62 | span := exporter.GetSpans()[0] 63 | 64 | if span.Name != spanName { 65 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 66 | } 67 | 68 | if span.SpanKind != trace.SpanKindClient { 69 | t.Errorf("wrong Span kind, expected, got %v, want %v", span.SpanKind, trace.SpanKindClient) 70 | } 71 | }) 72 | } 73 | 74 | func TestElasticsearchOpenTelemetry_BeforeRequest(t *testing.T) { 75 | t.Run("BeforeRequest noop", func(t *testing.T) { 76 | _, _, instrument := NewTestOpenTelemetry() 77 | 78 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) 79 | if err != nil { 80 | t.Fatalf("error while creating request") 81 | } 82 | snapshot := req.Clone(context.Background()) 83 | instrument.BeforeRequest(req, endpoint) 84 | 85 | if !reflect.DeepEqual(req, snapshot) { 86 | t.Fatalf("request should not have changed") 87 | } 88 | }) 89 | } 90 | 91 | func TestElasticsearchOpenTelemetry_AfterRequest(t *testing.T) { 92 | t.Run("AfterRequest", func(t *testing.T) { 93 | exporter, provider, instrument := NewTestOpenTelemetry() 94 | fullUrl := "http://elastic:elastic@localhost:9200/test-index/_search" 95 | 96 | ctx := instrument.Start(context.Background(), spanName) 97 | req, err := http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, nil) 98 | if err != nil { 99 | t.Fatalf("error while creating request") 100 | } 101 | instrument.AfterRequest(req, "elasticsearch", endpoint) 102 | instrument.Close(ctx) 103 | err = provider.ForceFlush(context.Background()) 104 | if err != nil { 105 | t.Fatal(err) 106 | } 107 | 108 | if len(exporter.GetSpans()) != 1 { 109 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 110 | } 111 | 112 | span := exporter.GetSpans()[0] 113 | 114 | if span.Name != spanName { 115 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 116 | } 117 | 118 | for _, attribute := range span.Attributes { 119 | switch attribute.Key { 120 | case attrDbSystem: 121 | if !attribute.Valid() && attribute.Value.AsString() != "elasticsearch" { 122 | t.Errorf("invalid %v, got %v, want %v", attrDbSystem, attribute.Value.AsString(), "elasticsearch") 123 | } 124 | case attrDbOperation: 125 | if !attribute.Valid() && attribute.Value.AsString() != endpoint { 126 | t.Errorf("invalid %v, got %v, want %v", attrDbOperation, attribute.Value.AsString(), endpoint) 127 | } 128 | case attrHttpRequestMethod: 129 | if !attribute.Valid() && attribute.Value.AsString() != http.MethodOptions { 130 | t.Errorf("invalid %v, got %v, want %v", attrHttpRequestMethod, attribute.Value.AsString(), http.MethodOptions) 131 | } 132 | case attrUrlFull: 133 | if !attribute.Valid() && attribute.Value.AsString() != fullUrl { 134 | t.Errorf("invalid %v, got %v, want %v", attrUrlFull, attribute.Value.AsString(), fullUrl) 135 | } 136 | case attrServerAddress: 137 | if !attribute.Valid() && attribute.Value.AsString() != "localhost" { 138 | t.Errorf("invalid %v, got %v, want %v", attrServerAddress, attribute.Value.AsString(), "localhost") 139 | } 140 | case attrServerPort: 141 | if !attribute.Valid() && attribute.Value.AsInt64() != 9200 { 142 | t.Errorf("invalid %v, got %v, want %v", attrServerPort, attribute.Value.AsInt64(), 9200) 143 | } 144 | } 145 | } 146 | }) 147 | } 148 | 149 | func TestElasticsearchOpenTelemetry_RecordError(t *testing.T) { 150 | exporter, provider, instrument := NewTestOpenTelemetry() 151 | 152 | ctx := instrument.Start(context.Background(), spanName) 153 | instrument.RecordError(ctx, fmt.Errorf("these are not the spans you are looking for")) 154 | instrument.Close(ctx) 155 | err := provider.ForceFlush(context.Background()) 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | 160 | if len(exporter.GetSpans()) != 1 { 161 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 162 | } 163 | 164 | span := exporter.GetSpans()[0] 165 | 166 | if span.Name != spanName { 167 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 168 | } 169 | 170 | if span.Status.Code != codes.Error { 171 | t.Errorf("expected the span to have a status.code Error, got %v, want %v", span.Status.Code, codes.Error) 172 | } 173 | } 174 | 175 | func TestElasticsearchOpenTelemetry_RecordClusterId(t *testing.T) { 176 | exporter, provider, instrument := NewTestOpenTelemetry() 177 | 178 | ctx := instrument.Start(context.Background(), spanName) 179 | clusterId := "randomclusterid" 180 | instrument.AfterResponse(ctx, &http.Response{Header: map[string][]string{ 181 | "X-Found-Handling-Cluster": {clusterId}, 182 | }}) 183 | instrument.Close(ctx) 184 | err := provider.ForceFlush(context.Background()) 185 | if err != nil { 186 | t.Fatal(err) 187 | } 188 | 189 | if len(exporter.GetSpans()) != 1 { 190 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 191 | } 192 | 193 | span := exporter.GetSpans()[0] 194 | 195 | if span.Name != spanName { 196 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 197 | } 198 | 199 | for _, attribute := range span.Attributes { 200 | switch attribute.Key { 201 | case attrDbElasticsearchClusterName: 202 | if !attribute.Valid() && attribute.Value.AsString() != clusterId { 203 | t.Errorf("invalid %v, got %v, want %v", attrServerAddress, attribute.Value.AsString(), clusterId) 204 | } 205 | } 206 | } 207 | } 208 | 209 | func TestElasticsearchOpenTelemetry_RecordNodeName(t *testing.T) { 210 | exporter, provider, instrument := NewTestOpenTelemetry() 211 | 212 | ctx := instrument.Start(context.Background(), spanName) 213 | nodeName := "randomnodename" 214 | instrument.AfterResponse(ctx, &http.Response{Header: map[string][]string{ 215 | "X-Found-Handling-Instance": {nodeName}, 216 | }}) 217 | instrument.Close(ctx) 218 | err := provider.ForceFlush(context.Background()) 219 | if err != nil { 220 | t.Fatal(err) 221 | } 222 | 223 | if len(exporter.GetSpans()) != 1 { 224 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 225 | } 226 | 227 | span := exporter.GetSpans()[0] 228 | 229 | if span.Name != spanName { 230 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 231 | } 232 | 233 | for _, attribute := range span.Attributes { 234 | switch attribute.Key { 235 | case attrDbElasticsearchNodeName: 236 | if !attribute.Valid() && attribute.Value.AsString() != nodeName { 237 | t.Errorf("invalid %v, got %v, want %v", attrDbElasticsearchNodeName, attribute.Value.AsString(), nodeName) 238 | } 239 | } 240 | } 241 | } 242 | 243 | func TestElasticsearchOpenTelemetry_RecordPathPart(t *testing.T) { 244 | exporter, provider, instrument := NewTestOpenTelemetry() 245 | indexName := "test-index" 246 | pretty := "true" 247 | 248 | ctx := instrument.Start(context.Background(), spanName) 249 | instrument.RecordPathPart(ctx, "index", indexName) 250 | instrument.RecordPathPart(ctx, "pretty", pretty) 251 | instrument.Close(ctx) 252 | err := provider.ForceFlush(context.Background()) 253 | if err != nil { 254 | t.Fatal(err) 255 | } 256 | 257 | if len(exporter.GetSpans()) != 1 { 258 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 259 | } 260 | 261 | span := exporter.GetSpans()[0] 262 | 263 | if span.Name != spanName { 264 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 265 | } 266 | 267 | for _, attribute := range span.Attributes { 268 | switch attribute.Key { 269 | case attrPathParts + "index": 270 | if !attribute.Valid() && attribute.Value.AsString() != indexName { 271 | t.Errorf("invalid %v, got %v, want %v", attrPathParts+"index", attribute.Value.AsString(), indexName) 272 | } 273 | case attrPathParts + "pretty": 274 | if !attribute.Valid() && attribute.Value.AsString() != indexName { 275 | t.Errorf("invalid %v, got %v, want %v", attrPathParts+"pretty", attribute.Value.AsString(), indexName) 276 | } 277 | } 278 | } 279 | } 280 | 281 | func TestElasticsearchOpenTelemetry_RecordRequestBody(t *testing.T) { 282 | exporter, provider, instrument := NewTestOpenTelemetry() 283 | fullUrl := "http://elastic:elastic@localhost:9200/test-index/_search" 284 | query := `{"query": {"match_all": {}}}` 285 | 286 | // Won't log query 287 | ctx := instrument.Start(context.Background(), spanName) 288 | _, err := http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, strings.NewReader(query)) 289 | if err != nil { 290 | t.Fatalf("error while creating request") 291 | } 292 | if reader := instrument.RecordRequestBody(ctx, "foo.endpoint", strings.NewReader(query)); reader != nil { 293 | t.Errorf("returned reader should be nil") 294 | } 295 | instrument.Close(ctx) 296 | 297 | // Will log query 298 | secondCtx := instrument.Start(context.Background(), spanName) 299 | _, err = http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, strings.NewReader(query)) 300 | if err != nil { 301 | t.Fatalf("error while creating request") 302 | } 303 | if reader := instrument.RecordRequestBody(secondCtx, "search", strings.NewReader(query)); reader == nil { 304 | t.Errorf("returned reader should not be nil") 305 | } 306 | instrument.Close(secondCtx) 307 | 308 | err = provider.ForceFlush(context.Background()) 309 | if err != nil { 310 | t.Fatal(err) 311 | } 312 | 313 | if len(exporter.GetSpans()) != 2 { 314 | t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) 315 | } 316 | 317 | span := exporter.GetSpans()[0] 318 | if span.Name != spanName { 319 | t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) 320 | } 321 | 322 | for _, attribute := range span.Attributes { 323 | switch attribute.Key { 324 | case attrDbStatement: 325 | t.Errorf("span should not have a %v entry", attrDbStatement) 326 | } 327 | } 328 | 329 | querySpan := exporter.GetSpans()[1] 330 | if querySpan.Name != spanName { 331 | t.Errorf("invalid span name, got %v, want %v", querySpan.Name, spanName) 332 | } 333 | 334 | for _, attribute := range querySpan.Attributes { 335 | switch attribute.Key { 336 | case attrDbStatement: 337 | if !attribute.Valid() && attribute.Value.AsString() != query { 338 | t.Errorf("invalid query provided, got %v, want %v", attribute.Value.AsString(), query) 339 | } 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /elastictransport/logger.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "bufio" 22 | "bytes" 23 | "encoding/json" 24 | "fmt" 25 | "io" 26 | "io/ioutil" 27 | "net/http" 28 | "net/url" 29 | "strconv" 30 | "strings" 31 | "time" 32 | ) 33 | 34 | var debugLogger DebuggingLogger 35 | 36 | // Logger defines an interface for logging request and response. 37 | // 38 | type Logger interface { 39 | // LogRoundTrip should not modify the request or response, except for consuming and closing the body. 40 | // Implementations have to check for nil values in request and response. 41 | LogRoundTrip(*http.Request, *http.Response, error, time.Time, time.Duration) error 42 | // RequestBodyEnabled makes the client pass a copy of request body to the logger. 43 | RequestBodyEnabled() bool 44 | // ResponseBodyEnabled makes the client pass a copy of response body to the logger. 45 | ResponseBodyEnabled() bool 46 | } 47 | 48 | // DebuggingLogger defines the interface for a debugging logger. 49 | // 50 | type DebuggingLogger interface { 51 | Log(a ...interface{}) error 52 | Logf(format string, a ...interface{}) error 53 | } 54 | 55 | // TextLogger prints the log message in plain text. 56 | // 57 | type TextLogger struct { 58 | Output io.Writer 59 | EnableRequestBody bool 60 | EnableResponseBody bool 61 | } 62 | 63 | // ColorLogger prints the log message in a terminal-optimized plain text. 64 | // 65 | type ColorLogger struct { 66 | Output io.Writer 67 | EnableRequestBody bool 68 | EnableResponseBody bool 69 | } 70 | 71 | // CurlLogger prints the log message as a runnable curl command. 72 | // 73 | type CurlLogger struct { 74 | Output io.Writer 75 | EnableRequestBody bool 76 | EnableResponseBody bool 77 | } 78 | 79 | // JSONLogger prints the log message as JSON. 80 | // 81 | type JSONLogger struct { 82 | Output io.Writer 83 | EnableRequestBody bool 84 | EnableResponseBody bool 85 | } 86 | 87 | // debuggingLogger prints debug messages as plain text. 88 | // 89 | type debuggingLogger struct { 90 | Output io.Writer 91 | } 92 | 93 | // LogRoundTrip prints the information about request and response. 94 | // 95 | func (l *TextLogger) LogRoundTrip(req *http.Request, res *http.Response, err error, start time.Time, dur time.Duration) error { 96 | fmt.Fprintf(l.Output, "%s %s %s [status:%d request:%s]\n", 97 | start.Format(time.RFC3339), 98 | req.Method, 99 | req.URL.String(), 100 | resStatusCode(res), 101 | dur.Truncate(time.Millisecond), 102 | ) 103 | if l.RequestBodyEnabled() && req != nil && req.Body != nil && req.Body != http.NoBody { 104 | var buf bytes.Buffer 105 | if req.GetBody != nil { 106 | b, _ := req.GetBody() 107 | buf.ReadFrom(b) 108 | } else { 109 | buf.ReadFrom(req.Body) 110 | } 111 | logBodyAsText(l.Output, &buf, ">") 112 | } 113 | if l.ResponseBodyEnabled() && res != nil && res.Body != nil && res.Body != http.NoBody { 114 | defer res.Body.Close() 115 | var buf bytes.Buffer 116 | buf.ReadFrom(res.Body) 117 | logBodyAsText(l.Output, &buf, "<") 118 | } 119 | if err != nil { 120 | fmt.Fprintf(l.Output, "! ERROR: %v\n", err) 121 | } 122 | return nil 123 | } 124 | 125 | // RequestBodyEnabled returns true when the request body should be logged. 126 | func (l *TextLogger) RequestBodyEnabled() bool { return l.EnableRequestBody } 127 | 128 | // ResponseBodyEnabled returns true when the response body should be logged. 129 | func (l *TextLogger) ResponseBodyEnabled() bool { return l.EnableResponseBody } 130 | 131 | // LogRoundTrip prints the information about request and response. 132 | // 133 | func (l *ColorLogger) LogRoundTrip(req *http.Request, res *http.Response, err error, start time.Time, dur time.Duration) error { 134 | query, _ := url.QueryUnescape(req.URL.RawQuery) 135 | if query != "" { 136 | query = "?" + query 137 | } 138 | 139 | var ( 140 | status string 141 | color string 142 | ) 143 | 144 | status = res.Status 145 | switch { 146 | case res.StatusCode > 0 && res.StatusCode < 300: 147 | color = "\x1b[32m" 148 | case res.StatusCode > 299 && res.StatusCode < 500: 149 | color = "\x1b[33m" 150 | case res.StatusCode > 499: 151 | color = "\x1b[31m" 152 | default: 153 | status = "ERROR" 154 | color = "\x1b[31;4m" 155 | } 156 | 157 | fmt.Fprintf(l.Output, "%6s \x1b[1;4m%s://%s%s\x1b[0m%s %s%s\x1b[0m \x1b[2m%s\x1b[0m\n", 158 | req.Method, 159 | req.URL.Scheme, 160 | req.URL.Host, 161 | req.URL.Path, 162 | query, 163 | color, 164 | status, 165 | dur.Truncate(time.Millisecond), 166 | ) 167 | 168 | if l.RequestBodyEnabled() && req != nil && req.Body != nil && req.Body != http.NoBody { 169 | var buf bytes.Buffer 170 | if req.GetBody != nil { 171 | b, _ := req.GetBody() 172 | buf.ReadFrom(b) 173 | } else { 174 | buf.ReadFrom(req.Body) 175 | } 176 | fmt.Fprint(l.Output, "\x1b[2m") 177 | logBodyAsText(l.Output, &buf, " »") 178 | fmt.Fprint(l.Output, "\x1b[0m") 179 | } 180 | 181 | if l.ResponseBodyEnabled() && res != nil && res.Body != nil && res.Body != http.NoBody { 182 | defer res.Body.Close() 183 | var buf bytes.Buffer 184 | buf.ReadFrom(res.Body) 185 | fmt.Fprint(l.Output, "\x1b[2m") 186 | logBodyAsText(l.Output, &buf, " «") 187 | fmt.Fprint(l.Output, "\x1b[0m") 188 | } 189 | 190 | if err != nil { 191 | fmt.Fprintf(l.Output, "\x1b[31;1m» ERROR \x1b[31m%v\x1b[0m\n", err) 192 | } 193 | 194 | if l.RequestBodyEnabled() || l.ResponseBodyEnabled() { 195 | fmt.Fprintf(l.Output, "\x1b[2m%s\x1b[0m\n", strings.Repeat("─", 80)) 196 | } 197 | return nil 198 | } 199 | 200 | // RequestBodyEnabled returns true when the request body should be logged. 201 | func (l *ColorLogger) RequestBodyEnabled() bool { return l.EnableRequestBody } 202 | 203 | // ResponseBodyEnabled returns true when the response body should be logged. 204 | func (l *ColorLogger) ResponseBodyEnabled() bool { return l.EnableResponseBody } 205 | 206 | // LogRoundTrip prints the information about request and response. 207 | // 208 | func (l *CurlLogger) LogRoundTrip(req *http.Request, res *http.Response, err error, start time.Time, dur time.Duration) error { 209 | var b bytes.Buffer 210 | 211 | var query string 212 | qvalues := url.Values{} 213 | for k, v := range req.URL.Query() { 214 | if k == "pretty" { 215 | continue 216 | } 217 | for _, qv := range v { 218 | qvalues.Add(k, qv) 219 | } 220 | } 221 | if len(qvalues) > 0 { 222 | query = qvalues.Encode() 223 | } 224 | 225 | b.WriteString(`curl`) 226 | if req.Method == "HEAD" { 227 | b.WriteString(" --head") 228 | } else { 229 | fmt.Fprintf(&b, " -X %s", req.Method) 230 | } 231 | 232 | if len(req.Header) > 0 { 233 | for k, vv := range req.Header { 234 | if k == "Authorization" || k == "User-Agent" { 235 | continue 236 | } 237 | v := strings.Join(vv, ",") 238 | b.WriteString(fmt.Sprintf(" -H '%s: %s'", k, v)) 239 | } 240 | } 241 | 242 | b.WriteString(" '") 243 | b.WriteString(req.URL.Scheme) 244 | b.WriteString("://") 245 | b.WriteString(req.URL.Host) 246 | b.WriteString(req.URL.Path) 247 | b.WriteString("?pretty") 248 | if query != "" { 249 | fmt.Fprintf(&b, "&%s", query) 250 | } 251 | b.WriteString("'") 252 | 253 | if req != nil && req.Body != nil && req.Body != http.NoBody { 254 | var buf bytes.Buffer 255 | if req.GetBody != nil { 256 | b, _ := req.GetBody() 257 | buf.ReadFrom(b) 258 | } else { 259 | buf.ReadFrom(req.Body) 260 | } 261 | 262 | b.Grow(buf.Len()) 263 | b.WriteString(" -d \\\n'") 264 | json.Indent(&b, buf.Bytes(), "", " ") 265 | b.WriteString("'") 266 | } 267 | 268 | b.WriteRune('\n') 269 | 270 | var status string 271 | status = res.Status 272 | 273 | fmt.Fprintf(&b, "# => %s [%s] %s\n", start.UTC().Format(time.RFC3339), status, dur.Truncate(time.Millisecond)) 274 | if l.ResponseBodyEnabled() && res != nil && res.Body != nil && res.Body != http.NoBody { 275 | var buf bytes.Buffer 276 | buf.ReadFrom(res.Body) 277 | 278 | b.Grow(buf.Len()) 279 | b.WriteString("# ") 280 | json.Indent(&b, buf.Bytes(), "# ", " ") 281 | } 282 | 283 | b.WriteString("\n") 284 | if l.ResponseBodyEnabled() && res != nil && res.Body != nil && res.Body != http.NoBody { 285 | b.WriteString("\n") 286 | } 287 | 288 | b.WriteTo(l.Output) 289 | 290 | return nil 291 | } 292 | 293 | // RequestBodyEnabled returns true when the request body should be logged. 294 | func (l *CurlLogger) RequestBodyEnabled() bool { return l.EnableRequestBody } 295 | 296 | // ResponseBodyEnabled returns true when the response body should be logged. 297 | func (l *CurlLogger) ResponseBodyEnabled() bool { return l.EnableResponseBody } 298 | 299 | // LogRoundTrip prints the information about request and response. 300 | // 301 | func (l *JSONLogger) LogRoundTrip(req *http.Request, res *http.Response, err error, start time.Time, dur time.Duration) error { 302 | // https://github.com/elastic/ecs/blob/master/schemas/http.yml 303 | // 304 | // TODO(karmi): Research performance optimization of using sync.Pool 305 | 306 | bsize := 200 307 | var b = bytes.NewBuffer(make([]byte, 0, bsize)) 308 | var v = make([]byte, 0, bsize) 309 | 310 | appendTime := func(t time.Time) { 311 | v = v[:0] 312 | v = t.AppendFormat(v, time.RFC3339) 313 | b.Write(v) 314 | } 315 | 316 | appendQuote := func(s string) { 317 | v = v[:0] 318 | v = strconv.AppendQuote(v, s) 319 | b.Write(v) 320 | } 321 | 322 | appendInt := func(i int64) { 323 | v = v[:0] 324 | v = strconv.AppendInt(v, i, 10) 325 | b.Write(v) 326 | } 327 | 328 | port := req.URL.Port() 329 | 330 | b.WriteRune('{') 331 | // -- Timestamp 332 | b.WriteString(`"@timestamp":"`) 333 | appendTime(start.UTC()) 334 | b.WriteRune('"') 335 | // -- Event 336 | b.WriteString(`,"event":{`) 337 | b.WriteString(`"duration":`) 338 | appendInt(dur.Nanoseconds()) 339 | b.WriteRune('}') 340 | // -- URL 341 | b.WriteString(`,"url":{`) 342 | b.WriteString(`"scheme":`) 343 | appendQuote(req.URL.Scheme) 344 | b.WriteString(`,"domain":`) 345 | appendQuote(req.URL.Hostname()) 346 | if port != "" { 347 | b.WriteString(`,"port":`) 348 | b.WriteString(port) 349 | } 350 | b.WriteString(`,"path":`) 351 | appendQuote(req.URL.Path) 352 | b.WriteString(`,"query":`) 353 | appendQuote(req.URL.RawQuery) 354 | b.WriteRune('}') // Close "url" 355 | // -- HTTP 356 | b.WriteString(`,"http":`) 357 | // ---- Request 358 | b.WriteString(`{"request":{`) 359 | b.WriteString(`"method":`) 360 | appendQuote(req.Method) 361 | if l.RequestBodyEnabled() && req != nil && req.Body != nil && req.Body != http.NoBody { 362 | var buf bytes.Buffer 363 | if req.GetBody != nil { 364 | b, _ := req.GetBody() 365 | buf.ReadFrom(b) 366 | } else { 367 | buf.ReadFrom(req.Body) 368 | } 369 | 370 | b.Grow(buf.Len() + 8) 371 | b.WriteString(`,"body":`) 372 | appendQuote(buf.String()) 373 | } 374 | b.WriteRune('}') // Close "http.request" 375 | // ---- Response 376 | b.WriteString(`,"response":{`) 377 | b.WriteString(`"status_code":`) 378 | appendInt(int64(resStatusCode(res))) 379 | if l.ResponseBodyEnabled() && res != nil && res.Body != nil && res.Body != http.NoBody { 380 | defer res.Body.Close() 381 | var buf bytes.Buffer 382 | buf.ReadFrom(res.Body) 383 | 384 | b.Grow(buf.Len() + 8) 385 | b.WriteString(`,"body":`) 386 | appendQuote(buf.String()) 387 | } 388 | b.WriteRune('}') // Close "http.response" 389 | b.WriteRune('}') // Close "http" 390 | // -- Error 391 | if err != nil { 392 | b.WriteString(`,"error":{"message":`) 393 | appendQuote(err.Error()) 394 | b.WriteRune('}') // Close "error" 395 | } 396 | b.WriteRune('}') 397 | b.WriteRune('\n') 398 | b.WriteTo(l.Output) 399 | 400 | return nil 401 | } 402 | 403 | // RequestBodyEnabled returns true when the request body should be logged. 404 | func (l *JSONLogger) RequestBodyEnabled() bool { return l.EnableRequestBody } 405 | 406 | // ResponseBodyEnabled returns true when the response body should be logged. 407 | func (l *JSONLogger) ResponseBodyEnabled() bool { return l.EnableResponseBody } 408 | 409 | // Log prints the arguments to output in default format. 410 | // 411 | func (l *debuggingLogger) Log(a ...interface{}) error { 412 | _, err := fmt.Fprint(l.Output, a...) 413 | return err 414 | } 415 | 416 | // Logf prints formats the arguments and prints them to output. 417 | // 418 | func (l *debuggingLogger) Logf(format string, a ...interface{}) error { 419 | _, err := fmt.Fprintf(l.Output, format, a...) 420 | return err 421 | } 422 | 423 | func logBodyAsText(dst io.Writer, body io.Reader, prefix string) { 424 | scanner := bufio.NewScanner(body) 425 | for scanner.Scan() { 426 | s := scanner.Text() 427 | if s != "" { 428 | fmt.Fprintf(dst, "%s %s\n", prefix, s) 429 | } 430 | } 431 | } 432 | 433 | func duplicateBody(body io.ReadCloser) (io.ReadCloser, io.ReadCloser, error) { 434 | var ( 435 | b1 bytes.Buffer 436 | b2 bytes.Buffer 437 | tr = io.TeeReader(body, &b2) 438 | ) 439 | _, err := b1.ReadFrom(tr) 440 | if err != nil { 441 | return ioutil.NopCloser(io.MultiReader(&b1, errorReader{err: err})), ioutil.NopCloser(io.MultiReader(&b2, errorReader{err: err})), err 442 | } 443 | defer func() { body.Close() }() 444 | 445 | return ioutil.NopCloser(&b1), ioutil.NopCloser(&b2), nil 446 | } 447 | 448 | func resStatusCode(res *http.Response) int { 449 | if res == nil { 450 | return -1 451 | } 452 | return res.StatusCode 453 | } 454 | 455 | type errorReader struct{ err error } 456 | 457 | func (r errorReader) Read(p []byte) (int, error) { return 0, r.err } 458 | -------------------------------------------------------------------------------- /elastictransport/logger_benchmark_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport_test 22 | 23 | import ( 24 | "bytes" 25 | "io/ioutil" 26 | "net/http" 27 | "net/url" 28 | "testing" 29 | 30 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 31 | ) 32 | 33 | func BenchmarkTransportLogger(b *testing.B) { 34 | b.ReportAllocs() 35 | 36 | b.Run("Text", func(b *testing.B) { 37 | for i := 0; i < b.N; i++ { 38 | tp, _ := elastictransport.New(elastictransport.Config{ 39 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 40 | Transport: newFakeTransport(b), 41 | Logger: &elastictransport.TextLogger{Output: ioutil.Discard}, 42 | }) 43 | 44 | req, _ := http.NewRequest("GET", "/abc", nil) 45 | _, err := tp.Perform(req) 46 | if err != nil { 47 | b.Fatalf("Unexpected error: %s", err) 48 | } 49 | } 50 | }) 51 | 52 | b.Run("Text-Body", func(b *testing.B) { 53 | for i := 0; i < b.N; i++ { 54 | tp, _ := elastictransport.New(elastictransport.Config{ 55 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 56 | Transport: newFakeTransport(b), 57 | Logger: &elastictransport.TextLogger{Output: ioutil.Discard, EnableRequestBody: true, EnableResponseBody: true}, 58 | }) 59 | 60 | req, _ := http.NewRequest("GET", "/abc", nil) 61 | res, err := tp.Perform(req) 62 | if err != nil { 63 | b.Fatalf("Unexpected error: %s", err) 64 | } 65 | 66 | body, err := ioutil.ReadAll(res.Body) 67 | if err != nil { 68 | b.Fatalf("Error reading response body: %s", err) 69 | } 70 | res.Body = ioutil.NopCloser(bytes.NewBuffer(body)) 71 | if len(body) < 13 { 72 | b.Errorf("Error reading response body bytes, want=13, got=%d", len(body)) 73 | } 74 | } 75 | }) 76 | 77 | b.Run("JSON", func(b *testing.B) { 78 | for i := 0; i < b.N; i++ { 79 | tp, _ := elastictransport.New(elastictransport.Config{ 80 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 81 | Transport: newFakeTransport(b), 82 | Logger: &elastictransport.JSONLogger{Output: ioutil.Discard}, 83 | }) 84 | 85 | req, _ := http.NewRequest("GET", "/abc", nil) 86 | _, err := tp.Perform(req) 87 | if err != nil { 88 | b.Fatalf("Unexpected error: %s", err) 89 | } 90 | } 91 | }) 92 | 93 | b.Run("JSON-Body", func(b *testing.B) { 94 | for i := 0; i < b.N; i++ { 95 | tp, _ := elastictransport.New(elastictransport.Config{ 96 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 97 | Transport: newFakeTransport(b), 98 | Logger: &elastictransport.JSONLogger{Output: ioutil.Discard, EnableRequestBody: true, EnableResponseBody: true}, 99 | }) 100 | 101 | req, _ := http.NewRequest("GET", "/abc", nil) 102 | _, err := tp.Perform(req) 103 | if err != nil { 104 | b.Fatalf("Unexpected error: %s", err) 105 | } 106 | } 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /elastictransport/logger_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "encoding/json" 25 | "errors" 26 | "fmt" 27 | "io" 28 | "io/ioutil" 29 | "net/http" 30 | "net/url" 31 | "os" 32 | "regexp" 33 | "strings" 34 | "sync" 35 | "testing" 36 | "time" 37 | ) 38 | 39 | var ( 40 | _ = fmt.Print 41 | _ = os.Stdout 42 | ) 43 | 44 | func TestTransportLogger(t *testing.T) { 45 | newRoundTripper := func() http.RoundTripper { 46 | return &mockTransp{ 47 | RoundTripFunc: func(req *http.Request) (*http.Response, error) { 48 | return &http.Response{ 49 | Status: "200 OK", 50 | StatusCode: 200, 51 | ContentLength: 13, 52 | Header: http.Header(map[string][]string{"Content-Type": {"application/json"}}), 53 | Body: ioutil.NopCloser(strings.NewReader(`{"foo":"bar"}`)), 54 | }, nil 55 | }, 56 | } 57 | } 58 | 59 | t.Run("Defaults", func(t *testing.T) { 60 | var wg sync.WaitGroup 61 | 62 | tp, _ := New(Config{ 63 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 64 | Transport: newRoundTripper(), 65 | // Logger: ioutil.Discard, 66 | }) 67 | 68 | for i := 0; i < 100; i++ { 69 | wg.Add(1) 70 | go func() { 71 | defer wg.Done() 72 | 73 | req, _ := http.NewRequest("GET", "/abc", nil) 74 | _, err := tp.Perform(req) 75 | if err != nil { 76 | t.Errorf("Unexpected error: %s", err) 77 | return 78 | } 79 | }() 80 | } 81 | wg.Wait() 82 | }) 83 | 84 | t.Run("Nil", func(t *testing.T) { 85 | tp, _ := New(Config{ 86 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 87 | Transport: newRoundTripper(), 88 | Logger: nil, 89 | }) 90 | 91 | req, _ := http.NewRequest("GET", "/abc", nil) 92 | _, err := tp.Perform(req) 93 | if err != nil { 94 | t.Fatalf("Unexpected error: %s", err) 95 | } 96 | }) 97 | 98 | t.Run("No HTTP response", func(t *testing.T) { 99 | tp, _ := New(Config{ 100 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 101 | Transport: &mockTransp{ 102 | RoundTripFunc: func(req *http.Request) (*http.Response, error) { 103 | return nil, errors.New("Mock error") 104 | }, 105 | }, 106 | Logger: &TextLogger{Output: ioutil.Discard}, 107 | }) 108 | 109 | req, _ := http.NewRequest("GET", "/abc", nil) 110 | res, err := tp.Perform(req) 111 | if err == nil { 112 | t.Errorf("Expected error: %v", err) 113 | } 114 | if res != nil { 115 | t.Errorf("Expected nil response, got: %v", err) 116 | } 117 | }) 118 | 119 | t.Run("Keep response body", func(t *testing.T) { 120 | var dst strings.Builder 121 | 122 | tp, _ := New(Config{ 123 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 124 | Transport: newRoundTripper(), 125 | Logger: &TextLogger{Output: &dst, EnableRequestBody: true, EnableResponseBody: true}, 126 | }) 127 | 128 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 129 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 130 | 131 | res, err := tp.Perform(req) 132 | if err != nil { 133 | t.Fatalf("Unexpected error: %s", err) 134 | } 135 | 136 | body, err := ioutil.ReadAll(res.Body) 137 | if err != nil { 138 | t.Fatalf("Error reading response body: %s", err) 139 | } 140 | 141 | if len(dst.String()) < 1 { 142 | t.Errorf("Log is empty: %#v", dst.String()) 143 | } 144 | 145 | if len(body) < 1 { 146 | t.Fatalf("Body is empty: %#v", body) 147 | } 148 | }) 149 | 150 | t.Run("Text with body", func(t *testing.T) { 151 | var dst strings.Builder 152 | 153 | tp, _ := New(Config{ 154 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 155 | Transport: newRoundTripper(), 156 | Logger: &TextLogger{Output: &dst, EnableRequestBody: true, EnableResponseBody: true}, 157 | }) 158 | 159 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 160 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 161 | 162 | res, err := tp.Perform(req) 163 | if err != nil { 164 | t.Fatalf("Unexpected error: %s", err) 165 | } 166 | 167 | _, err = ioutil.ReadAll(res.Body) 168 | if err != nil { 169 | t.Fatalf("Error reading response body: %s", err) 170 | } 171 | 172 | output := dst.String() 173 | output = strings.TrimSuffix(output, "\n") 174 | // fmt.Println(output) 175 | 176 | lines := strings.Split(output, "\n") 177 | 178 | if len(lines) != 3 { 179 | t.Fatalf("Expected 3 lines, got %d", len(lines)) 180 | } 181 | 182 | if !strings.Contains(lines[0], "GET http://foo/abc?q=a,b") { 183 | t.Errorf("Unexpected output: %s", lines[0]) 184 | } 185 | 186 | if lines[1] != `> {"query":"42"}` { 187 | t.Errorf("Unexpected output: %s", lines[1]) 188 | } 189 | 190 | if lines[2] != `< {"foo":"bar"}` { 191 | t.Errorf("Unexpected output: %s", lines[1]) 192 | } 193 | }) 194 | 195 | t.Run("Color with body", func(t *testing.T) { 196 | var dst strings.Builder 197 | 198 | tp, _ := New(Config{ 199 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 200 | Transport: newRoundTripper(), 201 | Logger: &ColorLogger{Output: &dst, EnableRequestBody: true, EnableResponseBody: true}, 202 | }) 203 | 204 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 205 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 206 | 207 | res, err := tp.Perform(req) 208 | if err != nil { 209 | t.Fatalf("Unexpected error: %s", err) 210 | } 211 | 212 | _, err = ioutil.ReadAll(res.Body) 213 | if err != nil { 214 | t.Fatalf("Error reading response body: %s", err) 215 | } 216 | 217 | var output string 218 | stripANSI := regexp.MustCompile("(?sm)\x1b\\[.+?m([^\x1b]+?)|\x1b\\[0m") 219 | for _, v := range strings.Split(dst.String(), "\n") { 220 | if v != "" { 221 | output += stripANSI.ReplaceAllString(v, "$1") 222 | if !strings.HasSuffix(output, "\n") { 223 | output += "\n" 224 | } 225 | } 226 | } 227 | output = strings.TrimSuffix(output, "\n") 228 | // fmt.Println(output) 229 | 230 | lines := strings.Split(output, "\n") 231 | 232 | if len(lines) != 4 { 233 | t.Fatalf("Expected 4 lines, got %d", len(lines)) 234 | } 235 | 236 | if !strings.Contains(lines[0], "GET http://foo/abc?q=a,b") { 237 | t.Errorf("Unexpected output: %s", lines[0]) 238 | } 239 | 240 | if !strings.Contains(lines[1], `» {"query":"42"}`) { 241 | t.Errorf("Unexpected output: %s", lines[1]) 242 | } 243 | 244 | if !strings.Contains(lines[2], `« {"foo":"bar"}`) { 245 | t.Errorf("Unexpected output: %s", lines[2]) 246 | } 247 | }) 248 | 249 | t.Run("Curl", func(t *testing.T) { 250 | var dst strings.Builder 251 | 252 | tp, _ := New(Config{ 253 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 254 | Transport: newRoundTripper(), 255 | Logger: &CurlLogger{Output: &dst, EnableRequestBody: true, EnableResponseBody: true}, 256 | }) 257 | 258 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 259 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 260 | 261 | res, err := tp.Perform(req) 262 | if err != nil { 263 | t.Fatalf("Unexpected error: %s", err) 264 | } 265 | 266 | _, err = ioutil.ReadAll(res.Body) 267 | if err != nil { 268 | t.Fatalf("Error reading response body: %s", err) 269 | } 270 | 271 | output := dst.String() 272 | output = strings.TrimSuffix(output, "\n") 273 | 274 | lines := strings.Split(output, "\n") 275 | 276 | if len(lines) != 9 { 277 | t.Fatalf("Expected 9 lines, got %d", len(lines)) 278 | } 279 | 280 | if !strings.Contains(lines[0], "curl -X GET 'http://foo/abc?pretty&q=a%2Cb'") { 281 | t.Errorf("Unexpected output: %s", lines[0]) 282 | } 283 | }) 284 | 285 | t.Run("JSON", func(t *testing.T) { 286 | var dst strings.Builder 287 | 288 | tp, _ := New(Config{ 289 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 290 | Transport: newRoundTripper(), 291 | Logger: &JSONLogger{Output: &dst}, 292 | }) 293 | 294 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 295 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 296 | _, err := tp.Perform(req) 297 | if err != nil { 298 | t.Fatalf("Unexpected error: %s", err) 299 | } 300 | 301 | output := dst.String() 302 | output = strings.TrimSuffix(output, "\n") 303 | // fmt.Println(output) 304 | 305 | lines := strings.Split(output, "\n") 306 | 307 | if len(lines) != 1 { 308 | t.Fatalf("Expected 1 line, got %d", len(lines)) 309 | } 310 | 311 | var j map[string]interface{} 312 | if err := json.Unmarshal([]byte(output), &j); err != nil { 313 | t.Errorf("Error decoding JSON: %s", err) 314 | } 315 | 316 | domain := j["url"].(map[string]interface{})["domain"] 317 | if domain != "foo" { 318 | t.Errorf("Unexpected JSON output: %s", domain) 319 | } 320 | }) 321 | 322 | t.Run("JSON with request body", func(t *testing.T) { 323 | var dst strings.Builder 324 | 325 | tp, _ := New(Config{ 326 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 327 | Transport: newRoundTripper(), 328 | Logger: &JSONLogger{Output: &dst, EnableRequestBody: true}, 329 | }) 330 | 331 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 332 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 333 | 334 | res, err := tp.Perform(req) 335 | if err != nil { 336 | t.Fatalf("Unexpected error: %s", err) 337 | } 338 | 339 | _, err = ioutil.ReadAll(res.Body) 340 | if err != nil { 341 | t.Fatalf("Error reading response body: %s", err) 342 | } 343 | 344 | output := dst.String() 345 | output = strings.TrimSuffix(output, "\n") 346 | // fmt.Println(output) 347 | 348 | lines := strings.Split(output, "\n") 349 | 350 | if len(lines) != 1 { 351 | t.Fatalf("Expected 1 line, got %d", len(lines)) 352 | } 353 | 354 | var j map[string]interface{} 355 | if err := json.Unmarshal([]byte(output), &j); err != nil { 356 | t.Errorf("Error decoding JSON: %s", err) 357 | } 358 | 359 | body := j["http"].(map[string]interface{})["request"].(map[string]interface{})["body"].(string) 360 | if !strings.Contains(body, "query") { 361 | t.Errorf("Unexpected JSON output: %s", body) 362 | } 363 | }) 364 | 365 | t.Run("Custom", func(t *testing.T) { 366 | var dst strings.Builder 367 | 368 | tp, _ := New(Config{ 369 | URLs: []*url.URL{{Scheme: "http", Host: "foo"}}, 370 | Transport: newRoundTripper(), 371 | Logger: &CustomLogger{Output: &dst}, 372 | }) 373 | 374 | req, _ := http.NewRequest("GET", "/abc?q=a,b", nil) 375 | req.Body = ioutil.NopCloser(strings.NewReader(`{"query":"42"}`)) 376 | 377 | _, err := tp.Perform(req) 378 | if err != nil { 379 | t.Fatalf("Unexpected error: %s", err) 380 | } 381 | 382 | if !strings.HasPrefix(dst.String(), "GET http://foo/abc?q=a,b") { 383 | t.Errorf("Unexpected output: %s", dst.String()) 384 | } 385 | }) 386 | 387 | t.Run("Duplicate body", func(t *testing.T) { 388 | input := ResponseBody{content: strings.NewReader("FOOBAR")} 389 | 390 | b1, b2, err := duplicateBody(&input) 391 | if err != nil { 392 | t.Fatalf("Unexpected error: %s", err) 393 | } 394 | if !input.closed { 395 | t.Errorf("Expected input to be closed: %#v", input) 396 | } 397 | 398 | read, _ := ioutil.ReadAll(&input) 399 | if len(read) > 0 { 400 | t.Errorf("Expected input to be drained: %#v", input.content) 401 | } 402 | 403 | b1r, _ := ioutil.ReadAll(b1) 404 | b2r, _ := ioutil.ReadAll(b2) 405 | if len(b1r) != 6 || len(b2r) != 6 { 406 | t.Errorf( 407 | "Unexpected duplicate content, b1=%q (%db), b2=%q (%db)", 408 | string(b1r), len(b1r), string(b2r), len(b2r), 409 | ) 410 | } 411 | }) 412 | 413 | t.Run("Duplicate body with error", func(t *testing.T) { 414 | input := ResponseBody{content: &ErrorReader{r: strings.NewReader("FOOBAR")}} 415 | 416 | b1, b2, err := duplicateBody(&input) 417 | if err == nil { 418 | t.Errorf("Expected error, got: %v", err) 419 | } 420 | if err.Error() != "MOCK ERROR" { 421 | t.Errorf("Unexpected error value, expected [ERROR MOCK], got [%s]", err.Error()) 422 | } 423 | 424 | read, _ := ioutil.ReadAll(&input) 425 | if string(read) != "BAR" { 426 | t.Errorf("Unexpected undrained part: %q", read) 427 | } 428 | 429 | b2r, _ := ioutil.ReadAll(b2) 430 | if string(b2r) != "FOO" { 431 | t.Errorf("Unexpected value, b2=%q", string(b2r)) 432 | } 433 | 434 | b1c, err := ioutil.ReadAll(b1) 435 | if string(b1c) != "FOO" { 436 | t.Errorf("Unexpected value, b1=%q", string(b1c)) 437 | } 438 | if err == nil { 439 | t.Errorf("Expected error when reading b1, got: %v", err) 440 | } 441 | if err.Error() != "MOCK ERROR" { 442 | t.Errorf("Unexpected error value, expected [ERROR MOCK], got [%s]", err.Error()) 443 | } 444 | }) 445 | } 446 | 447 | func TestDebuggingLogger(t *testing.T) { 448 | logger := &debuggingLogger{Output: ioutil.Discard} 449 | 450 | t.Run("Log", func(t *testing.T) { 451 | if err := logger.Log("Foo"); err != nil { 452 | t.Errorf("Unexpected error: %s", err) 453 | } 454 | }) 455 | t.Run("Logf", func(t *testing.T) { 456 | if err := logger.Logf("Foo %d", 1); err != nil { 457 | t.Errorf("Unexpected error: %s", err) 458 | } 459 | }) 460 | } 461 | 462 | type CustomLogger struct { 463 | Output io.Writer 464 | } 465 | 466 | func (l *CustomLogger) LogRoundTrip( 467 | req *http.Request, 468 | res *http.Response, 469 | err error, 470 | start time.Time, 471 | dur time.Duration, 472 | ) error { 473 | fmt.Fprintln(l.Output, req.Method, req.URL, "->", res.Status) 474 | return nil 475 | } 476 | 477 | func (l *CustomLogger) RequestBodyEnabled() bool { return false } 478 | func (l *CustomLogger) ResponseBodyEnabled() bool { return false } 479 | 480 | type ResponseBody struct { 481 | content io.Reader 482 | closed bool 483 | } 484 | 485 | func (r *ResponseBody) Read(p []byte) (int, error) { 486 | return r.content.Read(p) 487 | } 488 | 489 | func (r *ResponseBody) Close() error { 490 | r.closed = true 491 | return nil 492 | } 493 | 494 | type ErrorReader struct { 495 | r io.Reader 496 | } 497 | 498 | func (r *ErrorReader) Read(p []byte) (int, error) { 499 | lr := io.LimitReader(r.r, 3) 500 | c, _ := lr.Read(p) 501 | return c, errors.New("MOCK ERROR") 502 | } 503 | -------------------------------------------------------------------------------- /elastictransport/metrics.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package elastictransport 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | "strconv" 24 | "strings" 25 | "sync" 26 | "time" 27 | ) 28 | 29 | // Measurable defines the interface for transports supporting metrics. 30 | // 31 | type Measurable interface { 32 | Metrics() (Metrics, error) 33 | } 34 | 35 | // connectionable defines the interface for transports returning a list of connections. 36 | // 37 | type connectionable interface { 38 | connections() []*Connection 39 | } 40 | 41 | // Metrics represents the transport metrics. 42 | // 43 | type Metrics struct { 44 | Requests int `json:"requests"` 45 | Failures int `json:"failures"` 46 | Responses map[int]int `json:"responses"` 47 | 48 | Connections []fmt.Stringer `json:"connections"` 49 | } 50 | 51 | // ConnectionMetric represents metric information for a connection. 52 | // 53 | type ConnectionMetric struct { 54 | URL string `json:"url"` 55 | Failures int `json:"failures,omitempty"` 56 | IsDead bool `json:"dead,omitempty"` 57 | DeadSince *time.Time `json:"dead_since,omitempty"` 58 | 59 | Meta struct { 60 | ID string `json:"id"` 61 | Name string `json:"name"` 62 | Roles []string `json:"roles"` 63 | } `json:"meta"` 64 | } 65 | 66 | // metrics represents the inner state of metrics. 67 | // 68 | type metrics struct { 69 | sync.RWMutex 70 | 71 | requests int 72 | failures int 73 | responses map[int]int 74 | 75 | connections []*Connection 76 | } 77 | 78 | // Metrics returns the transport metrics. 79 | // 80 | func (c *Client) Metrics() (Metrics, error) { 81 | if c.metrics == nil { 82 | return Metrics{}, errors.New("transport metrics not enabled") 83 | } 84 | c.metrics.RLock() 85 | defer c.metrics.RUnlock() 86 | 87 | if lockable, ok := c.pool.(sync.Locker); ok { 88 | lockable.Lock() 89 | defer lockable.Unlock() 90 | } 91 | 92 | m := Metrics{ 93 | Requests: c.metrics.requests, 94 | Failures: c.metrics.failures, 95 | Responses: make(map[int]int, len(c.metrics.responses)), 96 | } 97 | 98 | for code, num := range c.metrics.responses { 99 | m.Responses[code] = num 100 | } 101 | 102 | if pool, ok := c.pool.(connectionable); ok { 103 | for _, c := range pool.connections() { 104 | c.Lock() 105 | 106 | cm := ConnectionMetric{ 107 | URL: c.URL.String(), 108 | IsDead: c.IsDead, 109 | Failures: c.Failures, 110 | } 111 | 112 | if !c.DeadSince.IsZero() { 113 | cm.DeadSince = &c.DeadSince 114 | } 115 | 116 | if c.ID != "" { 117 | cm.Meta.ID = c.ID 118 | } 119 | 120 | if c.Name != "" { 121 | cm.Meta.Name = c.Name 122 | } 123 | 124 | if len(c.Roles) > 0 { 125 | cm.Meta.Roles = c.Roles 126 | } 127 | 128 | m.Connections = append(m.Connections, cm) 129 | c.Unlock() 130 | } 131 | } 132 | 133 | return m, nil 134 | } 135 | 136 | // String returns the metrics as a string. 137 | // 138 | func (m Metrics) String() string { 139 | var ( 140 | i int 141 | b strings.Builder 142 | ) 143 | b.WriteString("{") 144 | 145 | b.WriteString("Requests:") 146 | b.WriteString(strconv.Itoa(m.Requests)) 147 | 148 | b.WriteString(" Failures:") 149 | b.WriteString(strconv.Itoa(m.Failures)) 150 | 151 | if len(m.Responses) > 0 { 152 | b.WriteString(" Responses: ") 153 | b.WriteString("[") 154 | 155 | for code, num := range m.Responses { 156 | b.WriteString(strconv.Itoa(code)) 157 | b.WriteString(":") 158 | b.WriteString(strconv.Itoa(num)) 159 | if i+1 < len(m.Responses) { 160 | b.WriteString(", ") 161 | } 162 | i++ 163 | } 164 | b.WriteString("]") 165 | } 166 | 167 | b.WriteString(" Connections: [") 168 | for i, c := range m.Connections { 169 | b.WriteString(c.String()) 170 | if i+1 < len(m.Connections) { 171 | b.WriteString(", ") 172 | } 173 | i++ 174 | } 175 | b.WriteString("]") 176 | 177 | b.WriteString("}") 178 | return b.String() 179 | } 180 | 181 | // String returns the connection information as a string. 182 | // 183 | func (cm ConnectionMetric) String() string { 184 | var b strings.Builder 185 | b.WriteString("{") 186 | b.WriteString(cm.URL) 187 | if cm.IsDead { 188 | fmt.Fprintf(&b, " dead=%v", cm.IsDead) 189 | } 190 | if cm.Failures > 0 { 191 | fmt.Fprintf(&b, " failures=%d", cm.Failures) 192 | } 193 | if cm.DeadSince != nil { 194 | fmt.Fprintf(&b, " dead_since=%s", cm.DeadSince.Local().Format(time.Stamp)) 195 | } 196 | b.WriteString("}") 197 | return b.String() 198 | } 199 | -------------------------------------------------------------------------------- /elastictransport/metrics_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build !integration 19 | // +build !integration 20 | 21 | package elastictransport 22 | 23 | import ( 24 | "fmt" 25 | "net/http" 26 | "net/url" 27 | "regexp" 28 | "testing" 29 | "time" 30 | ) 31 | 32 | func TestMetrics(t *testing.T) { 33 | t.Run("Metrics()", func(t *testing.T) { 34 | tp, _ := New( 35 | Config{ 36 | URLs: []*url.URL{ 37 | {Scheme: "http", Host: "foo1"}, 38 | {Scheme: "http", Host: "foo2"}, 39 | {Scheme: "http", Host: "foo3"}, 40 | }, 41 | DisableRetry: true, 42 | EnableMetrics: true, 43 | }, 44 | ) 45 | 46 | tp.metrics.requests = 3 47 | tp.metrics.failures = 4 48 | tp.metrics.responses[200] = 1 49 | tp.metrics.responses[404] = 2 50 | 51 | req, _ := http.NewRequest("HEAD", "/", nil) 52 | tp.Perform(req) 53 | 54 | m, err := tp.Metrics() 55 | if err != nil { 56 | t.Fatalf("Unexpected error: %s", err) 57 | } 58 | 59 | fmt.Println(m) 60 | 61 | if m.Requests != 4 { 62 | t.Errorf("Unexpected output, want=4, got=%d", m.Requests) 63 | } 64 | if m.Failures != 5 { 65 | t.Errorf("Unexpected output, want=5, got=%d", m.Failures) 66 | } 67 | if len(m.Responses) != 2 { 68 | t.Errorf("Unexpected output: %+v", m.Responses) 69 | } 70 | if len(m.Connections) != 3 { 71 | t.Errorf("Unexpected output: %+v", m.Connections) 72 | } 73 | }) 74 | 75 | t.Run("Metrics() when not enabled", func(t *testing.T) { 76 | tp, _ := New(Config{}) 77 | 78 | _, err := tp.Metrics() 79 | if err == nil { 80 | t.Fatalf("Expected error, got: %v", err) 81 | } 82 | }) 83 | 84 | t.Run("String()", func(t *testing.T) { 85 | var m ConnectionMetric 86 | 87 | m = ConnectionMetric{URL: "http://foo1"} 88 | 89 | if m.String() != "{http://foo1}" { 90 | t.Errorf("Unexpected output: %s", m) 91 | } 92 | 93 | tt, _ := time.Parse(time.RFC3339, "2010-11-11T11:00:00Z") 94 | m = ConnectionMetric{ 95 | URL: "http://foo2", 96 | IsDead: true, 97 | Failures: 123, 98 | DeadSince: &tt, 99 | } 100 | 101 | match, err := regexp.MatchString( 102 | `{http://foo2 dead=true failures=123 dead_since=Nov 11 \d+:00:00}`, 103 | m.String(), 104 | ) 105 | if err != nil { 106 | t.Fatalf("Unexpected error: %s", err) 107 | } 108 | 109 | if !match { 110 | t.Errorf("Unexpected output: %s", m) 111 | } 112 | }) 113 | } 114 | 115 | func TestTransportPerformAndReadMetricsResponses(t *testing.T) { 116 | t.Run("Read Metrics.Responses", func(t *testing.T) { 117 | u, _ := url.Parse("https://foo.com/bar") 118 | tp, _ := New(Config{ 119 | EnableMetrics: true, 120 | URLs: []*url.URL{u}, 121 | Transport: &mockTransp{ 122 | RoundTripFunc: func(req *http.Request) (*http.Response, error) { return &http.Response{Status: "MOCK"}, nil }, 123 | }}) 124 | 125 | ch := make(chan struct{}) 126 | go func() { 127 | for { 128 | select { 129 | case <-ch: 130 | break 131 | default: 132 | metrics, _ := tp.Metrics() 133 | for range metrics.Responses { 134 | } 135 | } 136 | } 137 | }() 138 | 139 | for i := 0; i < 100000; i++ { 140 | req, _ := http.NewRequest("GET", "/abc", nil) 141 | _, _ = tp.Perform(req) 142 | } 143 | 144 | ch <- struct{}{} 145 | close(ch) 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /elastictransport/testdata/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+jCCAeKgAwIBAgIRAPX2ep98yDY2s0ykr/Mv7DAwDQYJKoZIhvcNAQELBQAw 3 | EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0xOTExMDIwOTIyNDZaFw0yMDExMDEwOTIy 4 | NDZaMBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 5 | ggEKAoIBAQDB0m5QrKyFALYrB2Dpac8RK1cU3edoiowIS00GRHm3dp7oeqmbXwJ4 6 | RFDDejM1NkVigkLSzCoDZQIUDiNQ1q7TdWQyWpiTv8d1MlT8kIq3wup4DyFPKEDZ 7 | C1X1fZeitOhj08E5X55voq5QAQLfCeSCWB4aP8+/e3KMgSk4GjKxfkAjoT6fSc38 8 | atdRZ1TTzwCJupiAZ54zYlL7gGDjw8K8Y7xRUYD+QiD7onghb0+jJbhShTUO3Rli 9 | nMfdeqICGN8LOqBfOuxAm1tl/+LFnqI8B/tJ8uo3YrOH2CXt1v8EauASEnIRXKTX 10 | Nrf+y1+PZlYwIkCDVQW8oYCqXzexY/q5AgMBAAGjSzBJMA4GA1UdDwEB/wQEAwIF 11 | oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMBQGA1UdEQQNMAuC 12 | CWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEADamo8m4XkgvX56TDQJiII8Zl 13 | S5d1GnCKxzqHXbDse7d5fOUl3FjO5ZaMbn0CiExv8tWbHlg7P9NCsBrqAxUTReKE 14 | cWTywC99wvQzEqfSJfW/Q8vTbbOw4uM9RgYDG+mfk66KC+M2MN81i/cHUKxq7N7v 15 | 7Y6s16rtxqFzerGgAKLppg52RJNDhCDPumnSHp1rm4RjrLlG+qZpo3/37mXdNd5Z 16 | JvP/Uki4FrZw46TEbMw2f1hnXQtwgE5DoD6vDpNHG5ffVBpO6/OOfnQJtnmN3gsZ 17 | MmdgdEzroVPY6vs9WveNyIkIHip0QgHt7jk2vW3g1lOBw1cHuwZZkUeQ9yz3bQ== 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /elastictransport/testdata/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDB0m5QrKyFALYr 3 | B2Dpac8RK1cU3edoiowIS00GRHm3dp7oeqmbXwJ4RFDDejM1NkVigkLSzCoDZQIU 4 | DiNQ1q7TdWQyWpiTv8d1MlT8kIq3wup4DyFPKEDZC1X1fZeitOhj08E5X55voq5Q 5 | AQLfCeSCWB4aP8+/e3KMgSk4GjKxfkAjoT6fSc38atdRZ1TTzwCJupiAZ54zYlL7 6 | gGDjw8K8Y7xRUYD+QiD7onghb0+jJbhShTUO3RlinMfdeqICGN8LOqBfOuxAm1tl 7 | /+LFnqI8B/tJ8uo3YrOH2CXt1v8EauASEnIRXKTXNrf+y1+PZlYwIkCDVQW8oYCq 8 | XzexY/q5AgMBAAECggEAJUIEPrHSwAZw/jh+4p+aVFyWcsZtU2U+oeGHvuqu0xuU 9 | VHAv5lQPL3tCYzE8YsA5+kO8ALWKZfimu6A0HbgHB1MLnbpYlh5JgzfXqm1GnSh0 10 | 1ftilcrRHGfXcEdiPL615WqxPAwrcp49D9gB60oFiSDTOIyHrPFYBbZWbBhtIj0i 11 | plEOSNlqch64oRuj/jWsUOSJk3MLm00lim81yfCG8vjQxsQ1MsdJh2E0GH58t4W6 12 | UUvHp85iZHrPLtWt9iI9hng9b51RBKKqqyjhfEW+jNgUdOlWBNkaCTzyCjRAu9fM 13 | 7VFhzCII6APVH4NhIbotx+NoA9sTSOdRFvcpPNJMGQKBgQD0vf8P/pQfOv176Bju 14 | fWno9ZL+bwCe1YW6FaHIMJ5vz6GR+kmDMxnjEJi9eCR9mZ8Ag4NotItTZ1lAeLdO 15 | MkfNXMolI3GTfSZ1qVoYuExOU3BMER2XgHCCo/jPWWL50YVUAUiIJd26KPn+CnrW 16 | EUQbtGMtnl0pg7chooeifgBHGwKBgQDKvNC0TJaQz/P8mTeJdGUfP5px37nYBM0Y 17 | hTWGGnMiU/OhjLLOysgUANj4XG12gzz1CMwND/HldgRWoZXqMEuRr/KoWTIREra1 18 | vjN+HEsTrujcB3+2peUBVTOFaoFfTH1ohbUp+wYw05y9Iz7DgY/EVmnZzusdxvxL 19 | UFda696+uwKBgGPkTH+9u71HeYCiSdLFk33HBdkde1ZY9jzuaVrpJTGjwGFxk6Ge 20 | MNmxw3XJ3LL7CZ/PDcqlrhw7mX0sCD09XnsefU9NOSUmtpTdq21dg5+QhMw3TCmy 21 | /bkEriALbs9iShXwdCdFtUsvQGIE6wAGihL4vGY5NfMk1JFA4jVbUkezAoGARcIO 22 | Nducextyolm96Efqe4QRClmmwpN0VpmPPyNetlMYo1+cLtdLXMal4V88MukZUl7C 23 | h0QTQZcICx7yTHBtsCVQY2i9d25u+74ETcJCevVWHk9ePGR8labRYXiyJy5UgGBx 24 | Y46CJM7LQbEc6XxtEWuCZHV0JPzQ1sFALYK3U/0CgYEAps8bjTS0BR5yb/NSO+YR 25 | Wd9/9Jh1fUgf/lWQUPBBdThOqWXtNYTHwTUdqmr6td+urlz6F2PpD7FGNBlNc8Ia 26 | 352T6k2fGNzce9bWdaxagbC4zDQeBWZtO8nLFQYUqkxf/UQSEjzHglZ0ZtpiRJ2M 27 | TrUFwQLe98VOuKjkOsqXn5A= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /elastictransport/testdata/nodes.info.json: -------------------------------------------------------------------------------- 1 | { 2 | "_nodes": { 3 | "total": 3, 4 | "successful": 3, 5 | "failed": 0 6 | }, 7 | "cluster_name": "elasticsearch", 8 | "nodes": { 9 | "8g1UNpQNS06tlH1DUMBNhg": { 10 | "name": "es1", 11 | "transport_address": "127.0.0.1:9300", 12 | "host": "127.0.0.1", 13 | "ip": "127.0.0.1", 14 | "version": "7.4.2", 15 | "build_flavor": "default", 16 | "build_type": "tar", 17 | "build_hash": "2f90bbf7b93631e52bafb59b3b049cb44ec25e96", 18 | "roles": [ 19 | "ingest", 20 | "master", 21 | "data", 22 | "ml" 23 | ], 24 | "attributes": { 25 | "ml.machine_memory": "8589934592", 26 | "xpack.installed": "true", 27 | "ml.max_open_jobs": "20" 28 | }, 29 | "http": { 30 | "bound_address": [ 31 | "[::1]:10001", 32 | "127.0.0.1:10001" 33 | ], 34 | "publish_address": "127.0.0.1:10001", 35 | "max_content_length_in_bytes": 104857600 36 | } 37 | }, 38 | "8YR2EBk_QvWI4guQK292RA": { 39 | "name": "es2", 40 | "transport_address": "127.0.0.1:9302", 41 | "host": "127.0.0.1", 42 | "ip": "127.0.0.1", 43 | "version": "7.4.2", 44 | "build_flavor": "default", 45 | "build_type": "tar", 46 | "build_hash": "2f90bbf7b93631e52bafb59b3b049cb44ec25e96", 47 | "roles": [ 48 | "ingest", 49 | "master", 50 | "data", 51 | "ml" 52 | ], 53 | "attributes": { 54 | "ml.machine_memory": "8589934592", 55 | "ml.max_open_jobs": "20", 56 | "xpack.installed": "true" 57 | }, 58 | "http": { 59 | "bound_address": [ 60 | "127.0.0.1:10002", 61 | "[::1]:10002", 62 | "[fe80::1]:10002" 63 | ], 64 | "publish_address": "localhost/127.0.0.1:10002", 65 | "max_content_length_in_bytes": 104857600 66 | } 67 | }, 68 | "oSVIMafYQD-4kD0Lz6H4-g": { 69 | "name": "es3", 70 | "transport_address": "127.0.0.1:9301", 71 | "host": "127.0.0.1", 72 | "ip": "127.0.0.1", 73 | "version": "7.4.2", 74 | "build_flavor": "default", 75 | "build_type": "tar", 76 | "build_hash": "2f90bbf7b93631e52bafb59b3b049cb44ec25e96", 77 | "roles": [ 78 | "master" 79 | ], 80 | "attributes": { 81 | "ml.machine_memory": "8589934592", 82 | "ml.max_open_jobs": "20", 83 | "xpack.installed": "true" 84 | }, 85 | "http": { 86 | "bound_address": [ 87 | "[::1]:10003", 88 | "127.0.0.1:10003" 89 | ], 90 | "publish_address": "127.0.0.1:10003", 91 | "max_content_length_in_bytes": 104857600 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /elastictransport/version/version.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package version 19 | 20 | const Transport = "8.7.1-SNAPSHOT" 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elastic/elastic-transport-go/v8 2 | 3 | go 1.20 4 | 5 | require ( 6 | go.opentelemetry.io/otel v1.21.0 7 | go.opentelemetry.io/otel/sdk v1.21.0 8 | go.opentelemetry.io/otel/trace v1.21.0 9 | ) 10 | 11 | require ( 12 | github.com/go-logr/logr v1.3.0 // indirect 13 | github.com/go-logr/stdr v1.2.2 // indirect 14 | go.opentelemetry.io/otel/metric v1.21.0 // indirect 15 | golang.org/x/sys v0.14.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 3 | github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= 4 | github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 5 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 6 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 10 | go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 11 | go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 12 | go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 13 | go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 14 | go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= 15 | go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= 16 | go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 17 | go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 18 | golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= 19 | golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 20 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 21 | --------------------------------------------------------------------------------