├── .gitignore ├── .dockerignore ├── Dockerfile ├── Pipfile ├── CHANGELOG.md ├── Pipfile.lock ├── LICENSE ├── README.md └── sumologic_prometheus_scraper.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .idea/ 3 | *.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.5-alpine3.7 2 | 3 | RUN pip install --upgrade pip pipenv 4 | 5 | WORKDIR /opt/sumo 6 | 7 | COPY . /opt/sumo/ 8 | 9 | RUN pipenv install --system 10 | 11 | CMD ["python", "./sumologic_prometheus_scraper.py"] -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | apscheduler = "==3.5.3" 8 | asyncio = "==3.4.3" 9 | certifi = "==2018.10.15" 10 | click = "==7.0" 11 | prometheus-client = "==0.4.2" 12 | pytz = "==2018.7" 13 | requests = "==2.20.0" 14 | six = "==1.12.0" 15 | urllib3 = "==1.24.2" 16 | voluptuous = "==0.11.5" 17 | 18 | [dev-packages] 19 | black = "*" 20 | 21 | [requires] 22 | python_version = "3.6.5" 23 | 24 | [pipenv] 25 | allow_prereleases = true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | # [2.5.2] (2019-04-22) 6 | 7 | * lock dependencies, upgrade urllib3 to fix security vulnerability 8 | 9 | # [2.5.1] (2018-11-09) 10 | 11 | * fix filtering logic to support conditions where labels are not present 12 | 13 | # [2.5.0] (2018-11-02) 14 | 15 | * Add Support for Sumo Logic Prometheus Content Type 16 | 17 | # [2.4.2] (2018-10-29) 18 | 19 | * update pipfile.lock 20 | 21 | # [2.4.1] (2018-10-26) 22 | 23 | * set X-Sumo-Client header 24 | 25 | # [2.4.0] (2018-10-19) 26 | 27 | * Add ability to remove labels before sending to Sumo Logic 28 | * Refactor callback to allow callback to be optional and invoke callback per metric as opposed to per batch. 29 | 30 | # [2.3.0] (2018-09-09) 31 | 32 | * harden config validation 33 | * add discovery capabilities for metrics behind Kubernetes services 34 | 35 | # [2.2.3] (2018-08-01) 36 | 37 | * allow verify to be a boolean or string to allow skipping verification 38 | 39 | # [2.2.2] (2018-07-28) 40 | 41 | * refactor 42 | 43 | # [2.2.1] (2018-07-27) 44 | 45 | * remove print statement 46 | 47 | # [2.2.0] (2018-07-27) 48 | 49 | * Add ability to include/exclude metrics based on labels. 50 | * Fix bug that could cause filtering by metric name not to behave as expected. 51 | * Better error handling to reduce volume of logging. 52 | 53 | # [2.1.0] (2018-07-13) 54 | 55 | * Add ability to pass callback function to SumoPrometheusScraper 56 | 57 | # [2.0.0] (2018-07-06) 58 | 59 | * major refactor, which introduces some breaking changes for the better, hence the major version bump 60 | * use Pipfile for dependencies management and repeatable builds 61 | * include README.md, CHANGELOG.md, LICENSE and other files in the built image 62 | * wildcard support in include_metrics / exclude_metrics 63 | * stricter config validation 64 | * allow sumo_http_url to be defined in global config or overridden per target 65 | 66 | # [1.0.0] (2018-06-05) 67 | 68 | * allow configuration to use environment variables 69 | * clean up and refactoring 70 | 71 | # [0.0.8] (2018-06-05) 72 | 73 | * switch to alpine image 74 | * ensure job and instance are part of dimensions so they are included with all metrics 75 | 76 | # [0.0.7] (2018-06-05) 77 | 78 | * build in scheduling logic and allow targets to specify the interval they should run. 79 | * reject NaN values 80 | 81 | # [0.0.6] (2018-05-30) 82 | 83 | * async calls for targets and sending to sumo, add retry logic for posting to sumo. 84 | 85 | # [0.0.5] (2018-05-18) 86 | 87 | * Add support for verify cert and token. 88 | 89 | # [0.0.4] (2018-05-18) 90 | 91 | * Add retry logic to all target calls. 92 | 93 | # [0.0.3] (2018-05-18) 94 | 95 | * Better error handling on request to target for data. 96 | 97 | # [0.0.2] (2018-05-18) 98 | 99 | * Add ability to include metrics explicitly, in addition to current exclusion logic in each target definition. 100 | * If metric value is NaN, convert to 0. 101 | * Add target name, used to generate up metric to show that service is up and healthy. 102 | * Always send up metric. 103 | 104 | # [0.0.1] (2018-05-21) 105 | 106 | * Initial Release -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "eac21d0f42dd8339e9e9036cbbf31cedea4003510280e77325180983b0d940f7" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6.5" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "apscheduler": { 20 | "hashes": [ 21 | "sha256:6599bc78901ee7e9be85cbd073d9cc155c42d2bc867c5cde4d4d1cc339ebfbeb", 22 | "sha256:a8fe0c82d1c21bcf4a1b0e00aa35709f1f63fdd36446e406fa56cc0d51d3acc6" 23 | ], 24 | "index": "pypi", 25 | "version": "==3.5.3" 26 | }, 27 | "asyncio": { 28 | "hashes": [ 29 | "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", 30 | "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", 31 | "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", 32 | "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" 33 | ], 34 | "index": "pypi", 35 | "version": "==3.4.3" 36 | }, 37 | "certifi": { 38 | "hashes": [ 39 | "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", 40 | "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" 41 | ], 42 | "index": "pypi", 43 | "version": "==2018.10.15" 44 | }, 45 | "chardet": { 46 | "hashes": [ 47 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 48 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 49 | ], 50 | "version": "==3.0.4" 51 | }, 52 | "click": { 53 | "hashes": [ 54 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 55 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 56 | ], 57 | "index": "pypi", 58 | "version": "==7.0" 59 | }, 60 | "idna": { 61 | "hashes": [ 62 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 63 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 64 | ], 65 | "version": "==2.7" 66 | }, 67 | "prometheus-client": { 68 | "hashes": [ 69 | "sha256:046cb4fffe75e55ff0e6dfd18e2ea16e54d86cc330f369bebcc683475c8b68a9" 70 | ], 71 | "index": "pypi", 72 | "version": "==0.4.2" 73 | }, 74 | "pytz": { 75 | "hashes": [ 76 | "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", 77 | "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" 78 | ], 79 | "index": "pypi", 80 | "version": "==2018.7" 81 | }, 82 | "requests": { 83 | "hashes": [ 84 | "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", 85 | "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" 86 | ], 87 | "index": "pypi", 88 | "version": "==2.20.0" 89 | }, 90 | "six": { 91 | "hashes": [ 92 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 93 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 94 | ], 95 | "index": "pypi", 96 | "version": "==1.12.0" 97 | }, 98 | "tzlocal": { 99 | "hashes": [ 100 | "sha256:27d58a0958dc884d208cdaf45ef5892bf2a57d21d9611f2ac45e51f1973e8cab", 101 | "sha256:f124f198e5d86b3538b140883472beaa82d2c0efc0cd9694dfdbe39079e22e69" 102 | ], 103 | "version": "==2.0.0b1" 104 | }, 105 | "urllib3": { 106 | "hashes": [ 107 | "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", 108 | "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" 109 | ], 110 | "index": "pypi", 111 | "version": "==1.24.2" 112 | }, 113 | "voluptuous": { 114 | "hashes": [ 115 | "sha256:303542b3fc07fb52ec3d7a1c614b329cdbee13a9d681935353d8ea56a7bfa9f1", 116 | "sha256:567a56286ef82a9d7ae0628c5842f65f516abcb496e74f3f59f1d7b28df314ef" 117 | ], 118 | "index": "pypi", 119 | "version": "==0.11.5" 120 | } 121 | }, 122 | "develop": { 123 | "appdirs": { 124 | "hashes": [ 125 | "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", 126 | "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" 127 | ], 128 | "version": "==1.4.3" 129 | }, 130 | "attrs": { 131 | "hashes": [ 132 | "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", 133 | "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" 134 | ], 135 | "version": "==19.1.0" 136 | }, 137 | "black": { 138 | "hashes": [ 139 | "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", 140 | "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c" 141 | ], 142 | "index": "pypi", 143 | "version": "==19.3b0" 144 | }, 145 | "click": { 146 | "hashes": [ 147 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 148 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 149 | ], 150 | "index": "pypi", 151 | "version": "==7.0" 152 | }, 153 | "toml": { 154 | "hashes": [ 155 | "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", 156 | "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" 157 | ], 158 | "version": "==0.10.0" 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sumologic-prometheus-scraper 2 | The Sumo Logic Prometheus Scraper provides a configurable general purpose mechanism to ingest Prometheus formatted metrics into Sumo Logic. These metrics are not ingested from Prometheus, but from targets that Prometheus scrapes. 3 | 4 | ## Support 5 | 6 | The code in this repository has been developed in collaboration with the Sumo Logic community and is not supported via standard Sumo Logic Support channels. For any issues or questions please submit an issue within the GitHub repository. The maintainers of this project will work directly with the community to answer any questions, address bugs, or review any requests for new features. 7 | 8 | ## License 9 | Released under Apache 2.0 License. 10 | 11 | ## Usage 12 | 13 | This script can be run standalone or as a container. In order to use the script, you need to provide a configuration file that defines the targets that the script should scrape for metrics. The path to this configuration should be set in an environment variable `CONFIG_PATH`. Below is a basic example configuration. 14 | 15 | ``` 16 | { 17 | "global": { 18 | "sumo_http_url": "INSERT_SUMO_HTTP_SOURCE_URL_HERE", 19 | "source_category": "INSERT_SOURCE_CATEGORY", 20 | "source_host": "INSERT_SOURCE_HOST", 21 | "source_name": "INSERT_SOURCE_NAME", 22 | "dimensions": "INSERT_DIMENSIONS_HERE", 23 | "metadata": "INSERT_METADATA_HERE" 24 | }, 25 | "targets": [ 26 | { 27 | "name": "target-name-1", 28 | "url": "INSERT_PROMETHEUS_SCRAPE_TARGET_HERE", 29 | "exclude_metrics": ["EXCLUDE_METRIC_1", "EXCLUDE_METRIC_2", ...] 30 | } 31 | ] 32 | } 33 | ``` 34 | 35 | ### Config Properties 36 | 37 | | Key | Type | Description | Required | Default | 38 | | --- | -----| ----------- | -------- | ------- | 39 | | `global` | {} | This is the global settings that apply to all targets. | No | None | 40 | | `targets` | [] | A list of targets to scrape and sent to Sumo Logic | Yes | None | 41 | 42 | ### Global Properties 43 | 44 | All Global properties can be overridden per target. Each Global property applies to each target, unless the target overrides it. 45 | 46 | | Key | Type | Description | Required | Default | 47 | | --- | ----- | ----------- | -------- | ------- | 48 | | `sumo_http_url` | URL | The Sumo Logic HTTP source URL. This can be configured globally, or per target. | Yes (Unless set in Target) | None | 49 | | `run_interval_seconds` | int | The interval in seconds in which the target should be scraped. This can be configured globally, or per target. | No | 60 | 50 | | `target_threads` | int | The number of threads to use when POST metrics to Sumo Logic. | No | 10 | 51 | | `retries` | int | The number of times to retry sending data to Sumo Logic in the event of issue. | No | 5 | 52 | | `backoff_factor` | float | The back off factor to use when retrying. | No | .2 | 53 | | `source_category` | String | The source category to assign to all data from every target, unless overridden in target. | No | None | 54 | | `source_host` | String | The source host to assign to all data from every target, unless overridden in target. | No | None | 55 | | `source_name` | String | The source name to assign to all data from every target, unless overridden in target. | No | None | 56 | | `dimensions` | String | Comma-separated key=value list of additional dimensions to assign to all data from every target, unless overridden in target. | No | None | 57 | | `metadata` | String | Comma-separated key=value list of additional metadata to assign to all data from every target, unless overridden in target. | No | None | 58 | 59 | ### Target Properties 60 | | Key | Type | Description | Required | Default | 61 | | --- | ----- | ----------- | -------- | ------- | 62 | | `url` | String | The URL for the Prometheus target to scrape. | Yes | None | 63 | | `name` | String | The name of the target. Used to generate an `up` metric to show that target is up. | Yes | None | 64 | | `exclude_metrics` | \[String\]| A list of Strings of metric names to exclude. Metrics with this name will not be sent to Sumo Logic. | No | None | 65 | | `include_metrics` | \[String\]| A list of Strings of metric names to include. Metrics with this name will be sent to Sumo Logic, as long as they are not in the exclude list. | No | None | 66 | | `exclude_labels` | Dict | A dictionary of labels to exclude. Metrics with these labels will not be sent to Sumo Logic. | No | None | 67 | | `include_labels` | Dict | A dictionary of labels to include. Metrics with these labels will be sent to Sumo Logic, as long as they are not in the exclude list. | No | None | 68 | | `strip_labels` | \[String\]| A list of Strings of label keys to remove before sending to Sumo Logic. Labels with this key will be removed before the data is sent to Sumo Logic. | No | None | 69 | 70 | ### Auto Discovery For Kubernetes Services 71 | 72 | Release 2.3.0 adds support for auto discovery of URL's behind Kubernetes services. When configuring a target url, it is possible to provide a dictionary instead of a URL. The dictionary can have the following properties: 73 | 74 | | Key | Type | Description | Required | Default | 75 | | --- | ---- | ----------- | -------- | ------- | 76 | | `service` | str | The name of the Kubernetes Service. | Yes | None | 77 | | `namespace` | str | The name of the Kubernetes namespace the Service runs in. | Yes | None | 78 | | `protocol` | str | The protocol to use when connecting to the service. | Yes | http | 79 | | `path` | str | The path for the service endpoint for where metrics are exposed. | Yes | /metrics | 80 | 81 | For example, suppose you have a Service called `foo` in the `bar` Namespace. Lets say there are 3 pods in the deployment that the `foo` Service points to, each one exposing its custom metrics on `/metrics`. You could use the following configuration for the `url` to collect metrics from all 3 pods. 82 | 83 | ```json 84 | { 85 | "service": "foo", 86 | "namespace": "bar" 87 | } 88 | ``` 89 | 90 | If you were to simply use the Service URL (e.g. `http://foo.bar:8080/metrics`), the you would get the metrics from one pod each scrape, the pod that the Service would happen to choose. Using the dictionary configuration ensures you always get the metrics from all pods behind the Service. 91 | 92 | ### Including and Excluding metrics by Name 93 | 94 | For each target, you can provide a list of metrics to include (`include_metrics`) or exclude (`exclude_metrics`). If you are using include and exclude, then exclusion takes precedence. If you are using include then only metrics in the inclusion list will be sent to Sumo Logic, provided there is no exclusion list containing that same value. Both include and exclude lists support use of * and ? wildcards. 95 | 96 | ### Including and Excluding metrics by Label 97 | 98 | For each target, you can provide a dictionary of labels to include (`include_labels`) or exclude (`exclude_labels`). If you are using include and exclude, then exclusion takes precedence. If you are using include then only metrics in the inclusion list will be sent to Sumo Logic, provided there is no exclusion list containing that same value. Both include and exclude lists support use of * and ? wildcards. 99 | 100 | ### Removing Labels 101 | 102 | For each target, you can provide a list of label keys to remove before sending to Sumo Logic. These must be exact matches, no wild card support. 103 | 104 | ### Setup 105 | 106 | #### Create a hosted collector and HTTP source in Sumo 107 | 108 | In this step you create, on the Sumo service, an HTTP endpoint to receive your logs. This process involves creating an HTTP source on a hosted collector in Sumo. In Sumo, collectors use sources to receive data. 109 | 110 | 1. If you don’t already have a Sumo account, you can create one by clicking the **Free Trial** button on https://www.sumologic.com/. 111 | 2. Create a hosted collector, following the instructions on [Configure a Hosted Collector](https://help.sumologic.com/Send-Data/Hosted-Collectors/Configure-a-Hosted-Collector) in Sumo help. (If you already have a Sumo hosted collector that you want to use, skip this step.) 112 | 3. Create an HTTP source on the collector you created in the previous step. For instructions, see [HTTP Logs and Metrics Source](https://help.sumologic.com/Send-Data/Sources/02Sources-for-Hosted-Collectors/HTTP-Source) in Sumo help. 113 | 4. When you have configured the HTTP source, Sumo will display the URL of the HTTP endpoint. Make a note of the URL. You will use it when you configure the script to send data to Sumo. 114 | 115 | #### Deploy the script as you want to 116 | The script can be configured with the following environment variables to be set. 117 | 118 | | Variable | Description | Required | DEFAULT VALUE | 119 | | -------- | ----------- | -------- | ------------- | 120 | | `CONFIG_PATH` | The path to the configuration file. | YES | `./config.json` | 121 | | `LOGGING_LEVEL` | The logging level. | NO | `ERROR` | 122 | 123 | ##### Running locally 124 | 125 | 1. Clone this repo. 126 | 2. Create the configuration file. If config file is not in the same path as script, set CONFIG_PATH environment variable to config file path. 127 | 3. Install [pipenv](https://docs.pipenv.org/#install-pipenv-today) 128 | 4. Create local virtualenv with all required dependencies `pipenv install` 129 | 5. Activate created virtualenv by running `pipenv shell` 130 | 6. Run the script. `./sumologic_prometheus_scraper.py` 131 | 132 | ##### Running as a Docker Container 133 | 134 | The script is packaged as a Docker Container, however the config file is still required and no default is provided. 135 | 136 | ##### Updating python dependencies 137 | 138 | This project uses `Pipfile` and `Pipfile.lock` files to manage python dependencies and provide repeatable builds. 139 | To update packages you should run `pipenv update` or follow [pipenv upgrade workflow](https://docs.pipenv.org/basics/#example-pipenv-upgrade-workflow) 140 | 141 | ### Common Errors 142 | 143 | #### `Error: Invalid value for "config": Could not open file: config.json: No such file or directory` 144 | You did not provide the config path or set the CONFIG_PATH variable. 145 | 146 | #### `Error: Invalid value for "config": Could not open file: *** : No such file or directory` 147 | The config path is defined, but the file does not exist. Make sure the config path is correct and the file does exist. 148 | 149 | #### `Error: Invalid value for "config": Expecting ',' delimiter: line * column * (char ***)` 150 | The config file has invalid syntax and cannot be parsed as JSON. 151 | 152 | #### `Error: Invalid value for "targets": required key not provided.` 153 | There are no targets defined in the config file. 154 | 155 | #### `Error: Invalid value for "targets" / "0" / "sumo_http_url": expected a URL` 156 | The `sumo_http_url` is not a valid URL. 157 | -------------------------------------------------------------------------------- /sumologic_prometheus_scraper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import asyncio 4 | import click 5 | import concurrent.futures 6 | import functools 7 | import gzip 8 | import json 9 | import logging 10 | import os 11 | import re 12 | import fnmatch 13 | import requests 14 | import math 15 | import time 16 | import urllib3 17 | 18 | from apscheduler.schedulers.blocking import BlockingScheduler 19 | from apscheduler.executors.pool import ThreadPoolExecutor 20 | from itertools import islice 21 | from json.decoder import JSONDecodeError 22 | from prometheus_client.parser import text_string_to_metric_families 23 | from requests.adapters import HTTPAdapter 24 | from urllib3.util import Retry 25 | from voluptuous import ( 26 | ALLOW_EXTRA, 27 | All, 28 | Any, 29 | Boolean, 30 | IsFile, 31 | Length, 32 | Invalid, 33 | MultipleInvalid, 34 | Optional, 35 | Or, 36 | Range, 37 | Required, 38 | Schema, 39 | Url, 40 | ) 41 | 42 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 43 | logging_level = os.environ.get("LOGGING_LEVEL", "ERROR") 44 | logging_format = "%(asctime)s [level=%(levelname)s] [thread=%(threadName)s] [module=%(module)s] [line=%(lineno)d]: %(message)s" 45 | logging.basicConfig(level=logging_level, format=logging_format) 46 | log = logging.getLogger(__name__) 47 | scheduler = BlockingScheduler(timezone="UTC") 48 | monitors = {} 49 | 50 | 51 | def batches(iterator, batch_size: int): 52 | """ Yields lists of max batch_size from given iterator""" 53 | while True: 54 | batch = list(islice(iterator, batch_size)) 55 | if not batch: 56 | break 57 | yield batch 58 | 59 | 60 | def sanitize_labels(labels: dict): 61 | """Given prometheus metric sample labels, returns labels dict suitable for Prometheus format""" 62 | new_labels = "" 63 | for key, value in labels.items(): 64 | new_labels += "=".join([key, '"' + value + '"', ","]) 65 | return f"{{{new_labels[:-2]}}}" 66 | 67 | 68 | def match_regexp(glob_list: list, default: str): 69 | """Converts a list of glob matches into a single compiled regexp 70 | If list is empty, a compilation of default regexp is returned instead""" 71 | if not glob_list: 72 | return re.compile(default) 73 | return re.compile(r"|".join(fnmatch.translate(p) for p in glob_list)) 74 | 75 | 76 | class SumoHTTPAdapter(HTTPAdapter): 77 | CONFIG_TO_HEADER = { 78 | "source_category": "X-Sumo-Category", 79 | "source_name": "X-Sumo-Name", 80 | "source_host": "X-Sumo-Host", 81 | "metadata": "X-Sumo-Metadata", 82 | } 83 | 84 | def __init__(self, config, max_retries, **kwds): 85 | self._prepared_headers = self._prepare_headers(config) 86 | super().__init__(max_retries=max_retries, **kwds) 87 | 88 | def add_headers(self, request, **kwds): 89 | for k, v in self._prepared_headers.items(): 90 | request.headers[k] = v 91 | 92 | def _prepare_headers(self, config): 93 | headers = {} 94 | for config_key, header_name in self.CONFIG_TO_HEADER.items(): 95 | if config_key in config: 96 | headers[header_name] = config[config_key] 97 | 98 | dimensions = f"job={config['name']},instance={config['url']}" 99 | if "dimensions" in config: 100 | dimensions += f",{config['dimensions']}" 101 | headers["X-Sumo-Dimensions"] = dimensions 102 | return headers 103 | 104 | 105 | class SumoPrometheusScraperConfig: 106 | def __init__(self): 107 | self.global_config_schema = Schema( 108 | { 109 | Optional("sumo_http_url"): Url(), 110 | Required("run_interval_seconds", default=60): All(int, Range(min=1)), 111 | Required("target_threads", default=10): All(int, Range(min=1, max=50)), 112 | Required("batch_size", default=1000): All(int, Range(min=1)), 113 | Required("retries", default=5): All(int, Range(min=1, max=20)), 114 | Required("backoff_factor", default=0.2): All(float, Range(min=0)), 115 | "source_category": str, 116 | "source_host": str, 117 | "source_name": str, 118 | "dimensions": str, 119 | "metadata": str, 120 | } 121 | ) 122 | 123 | self.target_source_config = Schema( 124 | Or( 125 | {Required("url"): Url()}, 126 | {Required("service"): str, Required("namespace"): str}, 127 | ) 128 | ) 129 | 130 | url_schema = Schema( 131 | Or( 132 | Required("url"), 133 | Url(), 134 | { 135 | Required("service"): str, 136 | Required("namespace"): str, 137 | Required("path", default="/metrics"): str, 138 | Required("protocol", default="http"): str, 139 | }, 140 | ) 141 | ) 142 | 143 | self.target_config_schema = self.global_config_schema.extend( 144 | { 145 | Required("url", default={}): url_schema, 146 | Required("name"): str, 147 | Required("exclude_metrics", default=[]): list([str]), 148 | Required("include_metrics", default=[]): list([str]), 149 | Required("exclude_labels", default={}): Schema({}, extra=ALLOW_EXTRA), 150 | Required("include_labels", default={}): Schema({}, extra=ALLOW_EXTRA), 151 | Required("strip_labels", default=[]): list([str]), 152 | Required("should_callback", default=True): bool, 153 | "token_file_path": IsFile(), 154 | "verify": Any(Boolean(), str), 155 | # repeat keys from global to remove default values 156 | "sumo_http_url": Url(), 157 | "run_interval_seconds": All(int, Range(min=1)), 158 | "target_threads": All(int, Range(min=1, max=50)), 159 | "batch_size": All(int, Range(min=1)), 160 | "retries": All(int, Range(min=1, max=20)), 161 | "backoff_factor": All(float, Range(min=0)), 162 | } 163 | ) 164 | 165 | self.config_schema = Schema( 166 | All( 167 | { 168 | Required("global", default={}): self.global_config_schema, 169 | Required("targets"): All( 170 | Length(min=1, max=256), [self.target_config_schema] 171 | ), 172 | }, 173 | self.check_url, 174 | ) 175 | ) 176 | 177 | @staticmethod 178 | def check_url(config): 179 | if "global" in config: 180 | if "sumo_http_url" in config["global"]: 181 | return config 182 | 183 | for t in config["targets"]: 184 | if "sumo_http_url" not in t: 185 | raise Invalid("sumo_http_url must be set on target or global.") 186 | return config 187 | 188 | 189 | class SumoPrometheusScraper: 190 | def __init__(self, name: str, config: dict, callback=None): 191 | self._config = config 192 | self._name = name 193 | self._should_callback = config["should_callback"] 194 | self._batch_size = config["batch_size"] 195 | self._sumo_session = None 196 | self._scrape_session = None 197 | self._exclude_metrics_re = match_regexp( 198 | self._config["exclude_metrics"], default=r"$." 199 | ) 200 | self._include_metrics_re = match_regexp( 201 | self._config["include_metrics"], default=r".*" 202 | ) 203 | self._exclude_labels = self._config["exclude_labels"] 204 | self._include_labels = self._config["include_labels"] 205 | self._strip_labels = self._config["strip_labels"] 206 | self._callback = callback 207 | if callback and callable(self._callback): 208 | self._callback = functools.partial(callback) 209 | 210 | retries = config["retries"] 211 | 212 | self._scrape_session = requests.Session() 213 | sumo_retry = Retry( 214 | total=retries, 215 | read=retries, 216 | method_whitelist=frozenset(["POST", *Retry.DEFAULT_METHOD_WHITELIST]), 217 | connect=retries, 218 | backoff_factor=config["backoff_factor"], 219 | ) 220 | 221 | self._sumo_session = requests.Session() 222 | adapter = SumoHTTPAdapter(config, max_retries=sumo_retry) 223 | self._sumo_session.mount("http://", adapter) 224 | self._sumo_session.mount("https://", adapter) 225 | 226 | if "token_file_path" in self._config: 227 | with open(self._config["token_file_path"]) as f: 228 | token = f.read().strip() 229 | self._scrape_session.headers["Authorization"] = f"Bearer {token}" 230 | if "verify" in self._config: 231 | self._scrape_session.verify = self._config["verify"] 232 | 233 | def _parsed_samples(self, prometheus_metrics: str, scrape_ts: int): 234 | for metric_family in text_string_to_metric_families(prometheus_metrics): 235 | for sample in metric_family.samples: 236 | name, labels, value, ts, exemplar = sample 237 | if ( 238 | self._callback 239 | and callable(self._callback) 240 | and self._should_callback 241 | ): 242 | name, labels, value = self._callback(name, labels, value) 243 | if math.isnan(value): 244 | continue 245 | if ( 246 | self._include_metrics_re.match(name) 247 | and not self._exclude_metrics_re.match(name) 248 | and self._should_include(labels) 249 | and not self._should_exclude(labels) 250 | ): 251 | for label_key in self._strip_labels: 252 | labels.pop(label_key, None) 253 | yield f"{name}{sanitize_labels(labels)} {value} {scrape_ts}" 254 | 255 | def _should_include(self, labels): 256 | for key, value in self._include_labels.items(): 257 | if key in labels and not re.match(value, labels[key]): 258 | return False 259 | return True 260 | 261 | def _should_exclude(self, labels): 262 | for key, value in self._exclude_labels.items(): 263 | if key in labels and re.match(value, labels[key]): 264 | return True 265 | return False 266 | 267 | async def _post_to_sumo(self, resp, scrape_ts: int): 268 | with concurrent.futures.ThreadPoolExecutor( 269 | max_workers=self._config["target_threads"] 270 | ) as executor: 271 | event_loop = asyncio.get_event_loop() 272 | futures = [ 273 | event_loop.run_in_executor( 274 | executor, self._compress_and_send, batch 275 | ) 276 | for batch in batches(self._parsed_samples(resp.text, scrape_ts), self._batch_size) 277 | ] 278 | for _ in await asyncio.gather(*futures): 279 | pass 280 | 281 | def _compress_and_send(self, batch): 282 | body = "\n".join(batch).encode("utf-8") 283 | try: 284 | resp = self._sumo_session.post( 285 | self._config["sumo_http_url"], 286 | data=gzip.compress(body, compresslevel=1), 287 | headers={ 288 | "Content-Type": "application/vnd.sumologic.prometheus", 289 | "Content-Encoding": "gzip", 290 | "X-Sumo-Client": "prometheus-scraper", 291 | }, 292 | ) 293 | resp.raise_for_status() 294 | log.info( 295 | f"posting batch to Sumo logic for {self._config['name']} took {resp.elapsed.total_seconds()} seconds" 296 | ) 297 | except requests.exceptions.HTTPError as http_error: 298 | log.error( 299 | f"unable to send batch for {self._config['name']} to Sumo Logic, got back response {resp.status_code} and error {http_error}" 300 | ) 301 | 302 | def run(self): 303 | start = int(time.time()) 304 | try: 305 | resp = self._scrape_session.get(self._config["url"]) 306 | resp.raise_for_status() 307 | scrape_ts = int(time.time()) 308 | self.send_up(1) 309 | log.info( 310 | f"scrape of {self._config['name']} took {resp.elapsed.total_seconds()} seconds" 311 | ) 312 | event_loop = asyncio.new_event_loop() 313 | event_loop.run_until_complete(self._post_to_sumo(resp, scrape_ts)) 314 | 315 | log.info( 316 | f"total time taken for {self._config['name']} was {(time.time() - start)} seconds" 317 | ) 318 | except requests.exceptions.HTTPError as http_error: 319 | self.send_up(0) 320 | log.error( 321 | f"unable to send batch for {self._config['name']} to Sumo Logic, got back response {resp.status_code} and error {http_error}" 322 | ) 323 | 324 | def send_up(self, up): 325 | data = f"metric=up {up} {int(time.time())}" 326 | resp = self._sumo_session.post( 327 | self._config["sumo_http_url"], 328 | data=data, 329 | headers={ 330 | "Content-Type": "application/vnd.sumologic.carbon2", 331 | "X-Sumo-Client": "prometheus-scraper", 332 | }, 333 | ) 334 | log.debug( 335 | f"got back status code {resp.status_code} for up metric for {self._config['name']}" 336 | ) 337 | resp.raise_for_status() 338 | 339 | 340 | @scheduler.scheduled_job( 341 | "interval", id="synchronize", seconds=int(os.environ.get("SYNC_INTERVAL", "10")) 342 | ) 343 | def synchronize(): 344 | current_monitors = monitors.copy() 345 | for key, value in current_monitors.items(): 346 | endpoint = lookup_endpoint( 347 | value["original_target_url"]["service"], 348 | value["original_target_url"]["namespace"], 349 | ) 350 | if value["ip"] not in str(endpoint): 351 | log.debug( 352 | f"change to monitor {key} detected, ip {value['ip']} has been removed, will remove job" 353 | ) 354 | scheduler.remove_job(key) 355 | del monitors[key] 356 | for subset in endpoint["subsets"]: 357 | for idx, address in enumerate(subset["addresses"]): 358 | for port in subset["ports"]: 359 | url = f"{value['original_target_url']['protocol']}://{address['ip']}:{port['port']}{value['original_target_url']['path']}" 360 | job_id = f"{value['original_target_name']}_{url}" 361 | if not scheduler.get_job(job_id): 362 | log.debug( 363 | f"change to monitor {key} detected, ip {address['ip']} has been added, will add job" 364 | ) 365 | new_target = value["target"].copy() 366 | new_target["name"] = job_id 367 | new_target["url"] = url 368 | scraper = SumoPrometheusScraper(job_id, new_target) 369 | scheduler.add_job( 370 | func=scraper.run, 371 | name=job_id, 372 | id=job_id, 373 | trigger="interval", 374 | seconds=new_target["run_interval_seconds"], 375 | ) 376 | create_monitor( 377 | new_target, 378 | address["ip"], 379 | port["port"], 380 | value["original_target_url"], 381 | value["original_target_name"], 382 | ) 383 | log.debug(f"current monitors: {len(monitors)}") 384 | log.debug(f"current jobs: {len(scheduler.get_jobs())}") 385 | 386 | 387 | def lookup_endpoint(service, namespace): 388 | headers = {} 389 | with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f: 390 | token = f.read().strip() 391 | headers["Authorization"] = f"Bearer {token}" 392 | resp = requests.get( 393 | url=f"https://kubernetes.default.svc/api/v1/namespaces/{namespace}/endpoints/{service}", 394 | verify="/run/secrets/kubernetes.io/serviceaccount/ca.crt", 395 | headers=headers, 396 | ) 397 | resp.raise_for_status() 398 | return json.loads(resp.content) 399 | 400 | 401 | def expand_config(config): 402 | pre_target_length = len(config["targets"]) 403 | targets = [] 404 | for target in config["targets"]: 405 | if isinstance(target["url"], dict): 406 | log.debug(f"target {target['name']} needs to be expanded") 407 | endpoint = lookup_endpoint( 408 | target["url"]["service"], target["url"]["namespace"] 409 | ) 410 | for subset in endpoint["subsets"]: 411 | for idx, address in enumerate(subset["addresses"]): 412 | for port in subset["ports"]: 413 | new_target = target.copy() 414 | url = f"{target['url']['protocol']}://{address['ip']}:{port['port']}{target['url']['path']}" 415 | new_target["url"] = url 416 | new_target["name"] = f"{new_target['name']}_{url}" 417 | scheduler_config = {} 418 | scheduler_config.update(new_target) 419 | for k, v in config["global"].items(): 420 | scheduler_config.setdefault(k, v) 421 | scheduler_config = json.loads( 422 | os.path.expandvars(json.dumps(scheduler_config)) 423 | ) 424 | targets.append(scheduler_config) 425 | create_monitor( 426 | scheduler_config, 427 | address["ip"], 428 | port["port"], 429 | target["url"], 430 | target["name"], 431 | ) 432 | if isinstance(target["url"], str): 433 | targets.append(target) 434 | config["targets"] = targets 435 | log.debug( 436 | f"expanded config from {pre_target_length} targets to {len(targets)} targets" 437 | ) 438 | return config 439 | 440 | 441 | def create_monitor(target, ip, port, original_target_url, original_target_name): 442 | monitors[target["name"]] = { 443 | "target": target, 444 | "ip": ip, 445 | "port": port, 446 | "original_target_url": original_target_url, 447 | "original_target_name": original_target_name, 448 | } 449 | 450 | 451 | def validate_config_file(ctx, param, value): 452 | config = SumoPrometheusScraperConfig() 453 | try: 454 | return config.config_schema(json.load(value)) 455 | except JSONDecodeError as e: 456 | raise click.BadParameter(str(e), ctx=ctx, param=param) 457 | except MultipleInvalid as e: 458 | raise click.BadParameter(e.msg, ctx=ctx, param=param, param_hint=e.path) 459 | 460 | 461 | @click.command() 462 | @click.argument( 463 | "config", 464 | envvar="CONFIG_PATH", 465 | callback=validate_config_file, 466 | type=click.File("r"), 467 | default="config.json", 468 | ) 469 | def scrape(config): 470 | expanded_config = expand_config(config) 471 | scheduler.configure( 472 | timezone="UTC", 473 | executors={"default": ThreadPoolExecutor(len(expanded_config["targets"]))}, 474 | ) 475 | for target_config in expanded_config["targets"]: 476 | scheduler_config = {} 477 | scheduler_config.update(target_config) 478 | for k, v in config["global"].items(): 479 | scheduler_config.setdefault(k, v) 480 | name = target_config["name"] 481 | scheduler_config = json.loads(os.path.expandvars(json.dumps(scheduler_config))) 482 | scraper = SumoPrometheusScraper(name, scheduler_config) 483 | scheduler.add_job( 484 | func=scraper.run, 485 | name=name, 486 | id=name, 487 | trigger="interval", 488 | seconds=scheduler_config["run_interval_seconds"], 489 | ) 490 | scheduler.start() 491 | 492 | 493 | if __name__ == "__main__": 494 | scrape() 495 | --------------------------------------------------------------------------------