├── .github └── workflows │ └── main.yml ├── .gitignore ├── .promu.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── aws.go ├── collector.go ├── config.yml ├── config └── config.go ├── go.mod ├── go.sum ├── main.go └── utils.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | 10 | # Run tests for any PRs. 11 | pull_request: 12 | 13 | env: 14 | IMAGE_NAME: cloudwatch_exporter 15 | 16 | jobs: 17 | push: 18 | runs-on: ubuntu-latest 19 | if: github.event_name == 'push' 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Build image 24 | run: docker build . --file Dockerfile --tag $IMAGE_NAME 25 | 26 | - name: Log into registry 27 | run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login -u ${{ secrets.DOCKER_USER }} --password-stdin 28 | 29 | - name: Push image 30 | run: | 31 | IMAGE_ID=technofy/cloudwatch_exporter 32 | 33 | # Strip git ref prefix from version 34 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 35 | 36 | # Strip "v" prefix from tag name 37 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 38 | 39 | # Use Docker `latest` tag convention 40 | [ "$VERSION" == "master" ] && VERSION=latest 41 | 42 | echo IMAGE_ID=$IMAGE_ID 43 | echo VERSION=$VERSION 44 | 45 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 46 | docker push $IMAGE_ID:$VERSION 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | vendor/ 4 | /cloudwatch_exporter 5 | -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | go: 2 | cgo: false 3 | repository: 4 | path: github.com/Technofy/cloudwatch_exporter 5 | build: 6 | flags: -a -tags netgo 7 | ldflags: | 8 | -X {{repoPath}}/vendor/github.com/prometheus/common/version.Version={{.Version}} 9 | -X {{repoPath}}/vendor/github.com/prometheus/common/version.Revision={{.Revision}} 10 | -X {{repoPath}}/vendor/github.com/prometheus/common/version.Branch={{.Branch}} 11 | -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildUser={{user}}@{{host}} 12 | -X {{repoPath}}/vendor/github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} 13 | tarball: 14 | files: 15 | - LICENSE 16 | - config.yml 17 | crossbuild: 18 | platforms: 19 | - linux/amd64 20 | - linux/386 21 | - darwin/amd64 22 | - darwin/386 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #### Docker build image 2 | FROM golang:alpine AS builder 3 | RUN apk update && apk add --no-cache git 4 | WORKDIR /build 5 | COPY . . 6 | RUN CGO_ENABLED=0 GOOS=linux go build -o /out/cloudwatch_exporter 7 | 8 | 9 | #### Docker image 10 | FROM alpine:latest 11 | LABEL maintainer="Anthony Teisseire " 12 | 13 | COPY --from=builder /out/cloudwatch_exporter /bin/cloudwatch_exporter 14 | COPY config.yml /etc/cloudwatch_exporter/config.yml 15 | 16 | RUN apk update && \ 17 | apk add ca-certificates && \ 18 | update-ca-certificates 19 | 20 | EXPOSE 9042 21 | ENTRYPOINT [ "/bin/cloudwatch_exporter", "-config.file=/etc/cloudwatch_exporter/config.yml" ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2017 The Prometheus Authors 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | GO := go 15 | PROMU := $(GOPATH)/bin/promu 16 | pkgs = $(shell $(GO) list ./... | grep -v /vendor/) 17 | 18 | PREFIX ?= $(shell pwd) 19 | BIN_DIR ?= $(shell pwd) 20 | DOCKER_IMAGE_NAME ?= cloudwatch-exporter 21 | DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) 22 | 23 | 24 | all: format build test 25 | 26 | travis: dependency format build test 27 | 28 | style: 29 | @echo ">> checking code style" 30 | @! gofmt -d $(shell find . -path ./vendor -prune -o -name '*.go' -print) | grep '^' 31 | 32 | test: 33 | @echo ">> running tests" 34 | @$(GO) test -short $(pkgs) 35 | 36 | format: 37 | @echo ">> formatting code" 38 | @$(GO) fmt $(pkgs) 39 | 40 | vet: 41 | @echo ">> vetting code" 42 | @$(GO) vet $(pkgs) 43 | 44 | build: promu 45 | @echo ">> building binaries" 46 | @$(PROMU) build --prefix $(PREFIX) 47 | 48 | tarball: promu 49 | @echo ">> building release tarball" 50 | @$(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) 51 | 52 | docker: 53 | @echo ">> building docker image" 54 | @docker build -t "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" . 55 | 56 | dependency: 57 | @echo ">> installing dependency with dep" 58 | @go get -u github.com/golang/dep/cmd/dep 59 | @dep ensure 60 | promu: 61 | @GOOS=$(shell uname -s | tr A-Z a-z) \ 62 | GOARCH=$(subst x86_64,amd64,$(patsubst i%86,386,$(shell uname -m))) \ 63 | $(GO) get -u github.com/prometheus/promu 64 | 65 | 66 | .PHONY: all style format build test vet tarball docker promu 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudWatch Exporter 2 | 3 | An [AWS CloudWatch](http://aws.amazon.com/cloudwatch/) exporter for [Prometheus](https://github.com/prometheus/prometheus) coded in Go, with multi-region and dynamic target support. 4 | 5 | 6 | ## How to configure 7 | 8 | The configuration is in YAML and tries to stay in the same spirit as the official exporter. 9 | 10 | ```yaml 11 | tasks: 12 | - name: billing 13 | default_region: us-east-1 14 | metrics: 15 | - aws_namespace: "AWS/Billing" 16 | aws_dimensions: [Currency] 17 | aws_dimensions_select: 18 | Currency: [USD] 19 | aws_metric_name: EstimatedCharges 20 | aws_statistics: [Maximum] 21 | range_seconds: 86400 22 | 23 | - name: ec2_cloudwatch 24 | metrics: 25 | - aws_namespace: "AWS/EC2" 26 | aws_dimensions: [InstanceId] 27 | aws_dimensions_select: 28 | InstanceId: [$_target] 29 | aws_metric_name: CPUUtilization 30 | aws_statistics: [Average] 31 | 32 | - aws_namespace: "AWS/EC2" 33 | aws_dimensions: [InstanceId] 34 | aws_dimensions_select: 35 | InstanceId: [$_target] 36 | aws_metric_name: NetworkOut 37 | aws_statistics: [Average] 38 | 39 | - name: vpn_mon 40 | metrics: 41 | - aws_namespace: "AWS/VPN" 42 | aws_dimensions: [VpnId] 43 | aws_dimensions_select: 44 | VpnId: [$_target] 45 | aws_metric_name: TunnelState 46 | aws_statistics: [Average] 47 | range_seconds: 3600 48 | ``` 49 | 50 | 51 | ### What are "Tasks" and "$_target"? 52 | 53 | Tasks are used to describe a CloudWatch scrape that can be reused on a whole set of instances and even cross-region. 54 | 55 | The **$_target** token in the dimensions select is used to pass a parameter given by Prometheus (for example a \__meta tag with service discovery). 56 | 57 | For example a scrape URL looks like this: 58 | 59 | `http://localhost:9042/scrape?task=ec2_cloudwatch&target=i-0123456789®ion=eu-west-1` 60 | 61 | With the example configuration above, this means that the CloudWatch exporter will scrape the `CPUUtilization` and the `NetworkOut` metrics when the dimension `InstanceId` will be equal to `i-0123456789` in the `eu-west-1` region according to the configuration of the task: `ec2_cloudwatch`. 62 | 63 | ### Hot reload of the configuration 64 | 65 | Let's say you can't afford to kill the process and restart it for any reason and you need to modify the configuration on the fly. It's possible! Just call the `/reload` endpoint. 66 | 67 | 68 | ## How to configure Prometheus 69 | 70 | ```yaml 71 | - job_name: 'aws_billing' 72 | metrics_path: '/scrape' 73 | params: 74 | task: [billing] 75 | static_configs: 76 | - targets: ['localhost:9042'] 77 | 78 | - job_name: 'ec2_cloudwatch' 79 | metrics_path: '/scrape' 80 | ec2_sd_configs: 81 | - region: eu-west-1 82 | params: 83 | region: [eu-west-1] 84 | relabel_configs: 85 | - source_labels: [__meta_ec2_tag_role] 86 | regex: webapp 87 | action: keep 88 | - source_labels: [job] 89 | target_label: __param_task 90 | - source_labels: [__meta_ec2_instance_id] 91 | target_label: __param_target 92 | - target_label: __address__ 93 | replacement: 'localhost:9042' 94 | 95 | - job_name: 'vpn_mon' 96 | metrics_path: '/scrape' 97 | params: 98 | task: [vpn_mon] 99 | static_configs: 100 | - targets: ['vpn-aabbccdd'] 101 | relabel_configs: 102 | - source_labels: [__address__] 103 | target_label: __param_target 104 | - target_label: __address__ 105 | replacement: 'localhost:9042' 106 | ``` 107 | 108 | Thanks to Prometheus relabelling feature, in the second job configuration, we tell it to use the `job_name` as the `task` parameter and to use the `__meta_ec2_instance_id` as the `target` parameter. The region is specified in the `params` section. 109 | 110 | The Billing example is there to demonstrate the multi-region capability of this exporter, the `default_region` parameter is specified in the exporter's configuration. 111 | 112 | **Note:** It would also work if no default_region was specified but a `params` block with the `region` parameter was set in the Prometheus configuration. 113 | 114 | ## Endpoints 115 | 116 | 117 | | Endpoint | Description | 118 | | ------------- | -------------------------------------------- | 119 | | `/metrics` | Gathers metrics from the CloudWatch exporter itself such as the total number of requests made to the AWS CloudWatch API. 120 | | `/scrape` | Gathers metrics from the CloudWatch API depending on the task and (optionally) the target passed as parameters. 121 | | `/reload` | Does a live reload of the configuration without restarting the process 122 | 123 | ## Why this remake? 124 | 125 | We felt left out when we wanted to monitor hundreds of machines on AWS thanks to CloudWatch when using the original exporter. We wanted to be able to use the service EC2 discovery functionnality provided by Prometheus to dynamically monitor our fleet. 126 | 127 | Regarding our requirements, installing Java runtime was also a bit of an issue, so we decided to make it *"compliant"* with the rest of the Prometheus project by using Golang. 128 | 129 | ## TODO 130 | 131 | This exporter is still in its early stages! It still lacks ~~the `dimensions_select_regex` parameter~~ and the DynamoDB special use-cases. Any help and/or criticism is welcome! 132 | 133 | _`dimensions_select_regex` has been added [here](https://github.com/Technofy/cloudwatch_exporter/commit/880ea50f22f23497abaf02b1d306ccdb71cc7c58) 134 | 135 | ## End Note 136 | 137 | This exporter is largely inspired by the [official CloudWatch Exporter](https://github.com/prometheus/cloudwatch_exporter) and we'd like to thank all the contributors who participated to the original project. 138 | 139 | This project is licensed under the [Apache 2.0 license](https://github.com/Technofy/cloudwatch_exporter/blob/master/LICENSE). 140 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | -------------------------------------------------------------------------------- /aws.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aws/aws-sdk-go/aws" 6 | "github.com/aws/aws-sdk-go/aws/session" 7 | "github.com/aws/aws-sdk-go/service/cloudwatch" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "time" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | func getLatestDatapoint(datapoints []*cloudwatch.Datapoint) *cloudwatch.Datapoint { 15 | var latest *cloudwatch.Datapoint = nil 16 | 17 | for dp := range datapoints { 18 | if latest == nil || latest.Timestamp.Before(*datapoints[dp].Timestamp) { 19 | latest = datapoints[dp] 20 | } 21 | } 22 | 23 | return latest 24 | } 25 | 26 | // scrape makes the required calls to AWS CloudWatch by using the parameters in the cwCollector 27 | // Once converted into Prometheus format, the metrics are pushed on the ch channel. 28 | func scrape(collector *cwCollector, ch chan<- prometheus.Metric) { 29 | session := session.Must(session.NewSession(&aws.Config{ 30 | Region: aws.String(collector.Region), 31 | })) 32 | 33 | svc := cloudwatch.New(session) 34 | for m := range collector.Template.Metrics { 35 | metric := &collector.Template.Metrics[m] 36 | 37 | now := time.Now() 38 | end := now.Add(time.Duration(-metric.ConfMetric.DelaySeconds) * time.Second) 39 | 40 | params := &cloudwatch.GetMetricStatisticsInput{ 41 | EndTime: aws.Time(end), 42 | StartTime: aws.Time(end.Add(time.Duration(-metric.ConfMetric.RangeSeconds) * time.Second)), 43 | 44 | Period: aws.Int64(int64(metric.ConfMetric.PeriodSeconds)), 45 | MetricName: aws.String(metric.ConfMetric.Name), 46 | Namespace: aws.String(metric.ConfMetric.Namespace), 47 | Dimensions: []*cloudwatch.Dimension{}, 48 | Unit: nil, 49 | } 50 | 51 | dimensions:=[]*cloudwatch.Dimension{} 52 | 53 | //This map will hold dimensions name which has been already collected 54 | valueCollected := map[string]bool{} 55 | 56 | 57 | if len(metric.ConfMetric.DimensionsSelectRegex) == 0 { 58 | metric.ConfMetric.DimensionsSelectRegex = map[string]string{} 59 | } 60 | 61 | //Check for dimensions who does not have either select or dimensions select_regex and make them select everything using regex 62 | for _,dimension := range metric.ConfMetric.Dimensions { 63 | _, found := metric.ConfMetric.DimensionsSelect[dimension] 64 | _, found2 := metric.ConfMetric.DimensionsSelectRegex[dimension] 65 | if !found && !found2 { 66 | metric.ConfMetric.DimensionsSelectRegex[dimension]=".*" 67 | } 68 | } 69 | 70 | if metric.ConfMetric.Statistics != nil { 71 | params.SetStatistics(aws.StringSlice(metric.ConfMetric.Statistics)) 72 | } 73 | 74 | if metric.ConfMetric.ExtendedStatistics != nil { 75 | params.SetExtendedStatistics(aws.StringSlice(metric.ConfMetric.ExtendedStatistics)) 76 | } 77 | 78 | labels := make([]string, 0, len(metric.LabelNames)) 79 | 80 | // Loop through the dimensions selects to build the filters and the labels array 81 | for dim := range metric.ConfMetric.DimensionsSelect { 82 | for val := range metric.ConfMetric.DimensionsSelect[dim] { 83 | dimValue := metric.ConfMetric.DimensionsSelect[dim][val] 84 | 85 | // Replace $_target token by the actual URL target 86 | if dimValue == "$_target" { 87 | dimValue = collector.Target 88 | } 89 | 90 | dimensions = append(dimensions, &cloudwatch.Dimension{ 91 | Name: aws.String(dim), 92 | Value: aws.String(dimValue), 93 | }) 94 | 95 | labels = append(labels, dimValue) 96 | } 97 | } 98 | 99 | if len(dimensions) > 0 || len(metric.ConfMetric.Dimensions) ==0 { 100 | labels = append(labels, collector.Template.Task.Name) 101 | params.Dimensions=dimensions 102 | scrapeSingleDataPoint(collector,ch,params,metric,labels,svc) 103 | } 104 | 105 | //If no regex is specified, continue 106 | if (len(metric.ConfMetric.DimensionsSelectRegex)==0){ 107 | continue 108 | } 109 | 110 | 111 | // Get all the metric to select the ones who'll match the regex 112 | result, err := svc.ListMetrics(&cloudwatch.ListMetricsInput{ 113 | MetricName: aws.String(metric.ConfMetric.Name), 114 | Namespace: aws.String(metric.ConfMetric.Namespace), 115 | }) 116 | nextToken:=result.NextToken 117 | metrics:=result.Metrics 118 | totalRequests.Inc() 119 | 120 | if err != nil { 121 | fmt.Println(err) 122 | continue 123 | } 124 | 125 | for nextToken!=nil { 126 | result, err := svc.ListMetrics(&cloudwatch.ListMetricsInput{ 127 | MetricName: aws.String(metric.ConfMetric.Name), 128 | Namespace: aws.String(metric.ConfMetric.Namespace), 129 | NextToken: nextToken, 130 | }) 131 | if err != nil { 132 | fmt.Println(err) 133 | continue 134 | } 135 | nextToken=result.NextToken 136 | metrics=append(metrics,result.Metrics...) 137 | } 138 | 139 | //For each metric returned by aws 140 | for _,met := range result.Metrics { 141 | labels := make([]string, 0, len(metric.LabelNames)) 142 | dimensions=[]*cloudwatch.Dimension{} 143 | 144 | //Try to match each dimensions to the regex 145 | for _,dim := range met.Dimensions { 146 | dimRegex:=metric.ConfMetric.DimensionsSelectRegex[*dim.Name] 147 | if(dimRegex==""){ 148 | dimRegex="\\b"+strings.Join(metric.ConfMetric.DimensionsSelect[*dim.Name],"\\b|\\b")+"\\b" 149 | } 150 | 151 | match,_:=regexp.MatchString(dimRegex,*dim.Value) 152 | if match { 153 | dimensions=append(dimensions, &cloudwatch.Dimension{ 154 | Name: aws.String(*dim.Name), 155 | Value: aws.String(*dim.Value), 156 | }) 157 | labels = append(labels, *dim.Value) 158 | 159 | 160 | } 161 | } 162 | 163 | //Cheking if all dimensions matched 164 | if len(labels) == len(metric.ConfMetric.Dimensions) { 165 | 166 | //Checking if this couple of dimensions has already been scraped 167 | if _, ok := valueCollected[strings.Join(labels,";")]; ok { 168 | continue 169 | } 170 | 171 | //If no, then scrape them 172 | valueCollected[strings.Join(labels,";")]=true 173 | 174 | params.Dimensions = dimensions 175 | 176 | labels = append(labels, collector.Template.Task.Name) 177 | scrapeSingleDataPoint(collector,ch,params,metric,labels,svc) 178 | 179 | } 180 | } 181 | } 182 | } 183 | 184 | //Send a single dataPoint to the Prometheus lib 185 | func scrapeSingleDataPoint(collector *cwCollector, ch chan<- prometheus.Metric,params *cloudwatch.GetMetricStatisticsInput,metric *cwMetric,labels []string,svc *cloudwatch.CloudWatch) error { 186 | resp, err := svc.GetMetricStatistics(params) 187 | totalRequests.Inc() 188 | 189 | if err != nil { 190 | collector.ErroneousRequests.Inc() 191 | fmt.Println(err) 192 | return err 193 | } 194 | 195 | // There's nothing in there, don't publish the metric 196 | if len(resp.Datapoints) == 0 { 197 | return nil 198 | } 199 | // Pick the latest datapoint 200 | dp := getLatestDatapoint(resp.Datapoints) 201 | 202 | if dp.Sum != nil { 203 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.Sum), labels...) 204 | } 205 | 206 | if dp.Average != nil { 207 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.Average), labels...) 208 | } 209 | 210 | if dp.Maximum != nil { 211 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.Maximum), labels...) 212 | } 213 | 214 | if dp.Minimum != nil { 215 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.Minimum), labels...) 216 | } 217 | 218 | if dp.SampleCount != nil { 219 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.SampleCount), labels...) 220 | } 221 | 222 | for e := range dp.ExtendedStatistics { 223 | ch <- prometheus.MustNewConstMetric(metric.Desc, metric.ValType, float64(*dp.ExtendedStatistics[e]), labels...) 224 | } 225 | 226 | return nil 227 | } 228 | -------------------------------------------------------------------------------- /collector.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "github.com/Technofy/cloudwatch_exporter/config" 6 | "github.com/prometheus/client_golang/prometheus" 7 | "time" 8 | ) 9 | 10 | var ( 11 | templates = map[string]*cwCollectorTemplate{} 12 | ) 13 | 14 | type cwMetric struct { 15 | Desc *prometheus.Desc 16 | ValType prometheus.ValueType 17 | 18 | ConfMetric *config.Metric 19 | LabelNames []string 20 | LabelValues []string 21 | } 22 | 23 | type cwCollectorTemplate struct { 24 | Metrics []cwMetric 25 | Task *config.Task 26 | } 27 | 28 | type cwCollector struct { 29 | Region string 30 | Target string 31 | ScrapeTime prometheus.Gauge 32 | ErroneousRequests prometheus.Counter 33 | Template *cwCollectorTemplate 34 | } 35 | 36 | // generateTemplates creates pre-generated metrics descriptions so that only the metrics are created from them during a scrape. 37 | func generateTemplates(cfg *config.Settings) { 38 | for t := range cfg.Tasks { 39 | var template = new(cwCollectorTemplate) 40 | 41 | //Save the task it belongs to 42 | template.Task = &cfg.Tasks[t] 43 | 44 | //Pre-allocate at least a few metrics 45 | template.Metrics = make([]cwMetric, 0, len(cfg.Tasks[t].Metrics)) 46 | 47 | for m := range cfg.Tasks[t].Metrics { 48 | metric := &cfg.Tasks[t].Metrics[m] 49 | 50 | labels := make([]string, len(metric.Dimensions)) 51 | 52 | for i := range metric.Dimensions { 53 | labels[i] = toSnakeCase(metric.Dimensions[i]) 54 | } 55 | labels = append(labels, "task") 56 | 57 | for s := range metric.Statistics { 58 | template.Metrics = append(template.Metrics, cwMetric{ 59 | Desc: prometheus.NewDesc( 60 | safeName(toSnakeCase(metric.Namespace)+"_"+toSnakeCase(metric.Name)+"_"+toSnakeCase(metric.Statistics[s])), 61 | metric.Name, 62 | labels, 63 | nil), 64 | ValType: prometheus.GaugeValue, 65 | ConfMetric: metric, 66 | LabelNames: labels, 67 | }) 68 | } 69 | for s := range metric.ExtendedStatistics { 70 | template.Metrics = append(template.Metrics, cwMetric{ 71 | Desc: prometheus.NewDesc( 72 | safeName(toSnakeCase(metric.Namespace)+"_"+toSnakeCase(metric.Name)+"_"+toSnakeCase(metric.ExtendedStatistics[s])), 73 | metric.Name, 74 | labels, 75 | nil), 76 | ValType: prometheus.GaugeValue, 77 | ConfMetric: metric, 78 | LabelNames: labels, 79 | }) 80 | } 81 | } 82 | 83 | templates[cfg.Tasks[t].Name] = template 84 | } 85 | } 86 | 87 | // NewCwCollector creates a new instance of a CwCollector for a specific task 88 | // The newly created instance will reference its parent template so that metric descriptions are not recreated on every call. 89 | // It returns either a pointer to a new instance of cwCollector or an error. 90 | func NewCwCollector(target string, taskName string, region string) (*cwCollector, error) { 91 | // Check if task exists 92 | task, err := settings.GetTask(taskName) 93 | 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | if region == "" { 99 | if task.DefaultRegion == "" { 100 | return nil, errors.New("No region or default region set requested task") 101 | } else { 102 | region = task.DefaultRegion 103 | } 104 | } 105 | 106 | return &cwCollector{ 107 | Region: region, 108 | Target: target, 109 | ScrapeTime: prometheus.NewGauge(prometheus.GaugeOpts{ 110 | Name: "cloudwatch_exporter_scrape_duration_seconds", 111 | Help: "Time this CloudWatch scrape took, in seconds.", 112 | }), 113 | ErroneousRequests: prometheus.NewGauge(prometheus.GaugeOpts{ 114 | Name: "cloudwatch_exporter_erroneous_requests", 115 | Help: "The number of erroneous request made by this scrape.", 116 | }), 117 | Template: templates[taskName], 118 | }, nil 119 | } 120 | 121 | func (c *cwCollector) Collect(ch chan<- prometheus.Metric) { 122 | now := time.Now() 123 | scrape(c, ch) 124 | c.ScrapeTime.Set(time.Since(now).Seconds()) 125 | 126 | ch <- c.ScrapeTime 127 | ch <- c.ErroneousRequests 128 | } 129 | 130 | func (c *cwCollector) Describe(ch chan<- *prometheus.Desc) { 131 | ch <- c.ScrapeTime.Desc() 132 | ch <- c.ErroneousRequests.Desc() 133 | 134 | for m := range c.Template.Metrics { 135 | ch <- c.Template.Metrics[m].Desc 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: billing 3 | default_region: us-east-1 4 | metrics: 5 | - aws_namespace: "AWS/Billing" 6 | aws_dimensions: [Currency] 7 | aws_dimensions_select: 8 | Currency: [USD] 9 | aws_metric_name: EstimatedCharges 10 | aws_statistics: [Average] 11 | range_seconds: 86400 12 | 13 | - name: billing-per-service-partial-dim 14 | default_region: us-east-1 15 | metrics: 16 | - aws_namespace: "AWS/Billing" 17 | aws_dimensions: [ServiceName,Currency] 18 | aws_dimensions_select: 19 | ServiceName: [AWSDataTransfer,AWSLambda] 20 | aws_metric_name: EstimatedCharges 21 | aws_statistics: [Average] 22 | range_seconds: 86400 23 | 24 | - name: billing-per-service-partial-dim-regex 25 | default_region: us-east-1 26 | metrics: 27 | - aws_namespace: "AWS/Billing" 28 | aws_dimensions: [ServiceName,Currency] 29 | aws_dimensions_select_regex: 30 | ServiceName: AWS.* 31 | aws_metric_name: EstimatedCharges 32 | aws_statistics: [Average] 33 | range_seconds: 86400 34 | 35 | - name: billing-per-service-no-dim 36 | default_region: us-east-1 37 | metrics: 38 | - aws_namespace: "AWS/Billing" 39 | aws_dimensions: [ServiceName,Currency] 40 | aws_metric_name: EstimatedCharges 41 | aws_statistics: [Average] 42 | range_seconds: 86400 43 | 44 | 45 | - name: ec2_cloudwatch 46 | metrics: 47 | - aws_namespace: "AWS/EC2" 48 | aws_dimensions: [InstanceId] 49 | aws_dimensions_select: 50 | InstanceId: [$_target] 51 | aws_metric_name: CPUUtilization 52 | aws_statistics: [Average] 53 | 54 | - aws_namespace: "AWS/EC2" 55 | aws_dimensions: [InstanceId] 56 | aws_dimensions_select: 57 | InstanceId: [$_target] 58 | aws_metric_name: NetworkOut 59 | aws_statistics: [Average] 60 | 61 | - name: vpn_mon 62 | metrics: 63 | - aws_namespace: "AWS/VPN" 64 | aws_dimensions: [VpnId] 65 | aws_dimensions_select: 66 | VpnId: [$_target] 67 | aws_metric_name: TunnelState 68 | aws_statistics: [Average] 69 | range_seconds: 3600 70 | 71 | - aws_namespace: "AWS/VPN" 72 | aws_dimensions: [VpnId] 73 | aws_dimensions_select: 74 | VpnId: [$_target] 75 | aws_metric_name: TunnelDataIn 76 | aws_statistics: [Average] 77 | range_seconds: 3600 78 | 79 | - aws_namespace: "AWS/VPN" 80 | aws_dimensions: [VpnId] 81 | aws_dimensions_select: 82 | VpnId: [$_target] 83 | aws_metric_name: TunnelDataOut 84 | aws_statistics: [Average] 85 | range_seconds: 3600 86 | - name: lambda_duration 87 | default_region: eu-west-1 88 | metrics: 89 | - aws_namespace: "AWS/Lambda" 90 | aws_dimensions: [FunctionName] 91 | aws_dimensions_select_regex: 92 | FunctionName: .* 93 | aws_metric_name: Duration 94 | aws_statistics: [Maximum] 95 | 96 | - name: lambda_duration_no_select 97 | default_region: eu-west-1 98 | metrics: 99 | - aws_namespace: "AWS/Lambda" 100 | aws_dimensions: [FunctionName] 101 | aws_metric_name: Duration 102 | aws_statistics: [Maximum] 103 | 104 | - name: ses_no_dimensions 105 | default_region: us-east-1 106 | metrics: 107 | - aws_namespace: "AWS/SES" 108 | aws_metric_name: Reputation.BounceRate 109 | aws_statistics: [Maximum] 110 | range_seconds: 3600 111 | 112 | - name: api_gateway_extended_statistics 113 | default_region: us-east-1 114 | metrics: 115 | - aws_namespace: "AWS/ApiGateway" 116 | aws_dimensions: [ApiName] 117 | aws_metric_name: Latency 118 | aws_extended_statistics: [p95] 119 | - aws_namespace: "AWS/ApiGateway" 120 | aws_dimensions: [ApiName] 121 | aws_metric_name: Latency 122 | aws_extended_statistics: [p90] 123 | 124 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "gopkg.in/yaml.v2" 7 | "io/ioutil" 8 | "strings" 9 | ) 10 | 11 | type Metric struct { 12 | Namespace string `yaml:"aws_namespace"` 13 | Name string `yaml:"aws_metric_name"` 14 | 15 | Statistics []string `yaml:"aws_statistics"` 16 | ExtendedStatistics []string `yaml:"aws_extended_statistics,omitempty"` 17 | Dimensions []string `yaml:"aws_dimensions,omitempty"` 18 | DimensionsSelect map[string][]string `yaml:"aws_dimensions_select,omitempty"` 19 | DimensionsSelectRegex map[string]string `yaml:"aws_dimensions_select_regex,omitempty"` 20 | DimensionsSelectParam map[string][]string `yaml:"aws_dimensions_select_param,omitempty"` 21 | 22 | RangeSeconds int `yaml:"range_seconds,omitempty"` 23 | PeriodSeconds int `yaml:"period_seconds,omitempty"` 24 | DelaySeconds int `yaml:"delay_seconds,omitempty"` 25 | } 26 | 27 | type Task struct { 28 | Name string `yaml:"name"` 29 | DefaultRegion string `yaml:"default_region,omitempty"` 30 | Metrics []Metric `yaml:"metrics"` 31 | } 32 | 33 | type Settings struct { 34 | AutoReload bool `yaml:"auto_reload,omitempty"` 35 | ReloadDelay int `yaml:"auto_reload_delay,omitempty"` 36 | Tasks []Task `yaml:"tasks"` 37 | } 38 | 39 | func (s *Settings) GetTask(name string) (*Task, error) { 40 | for i := range s.Tasks { 41 | if strings.Compare(s.Tasks[i].Name, name) == 0 { 42 | return &s.Tasks[i], nil 43 | } 44 | } 45 | 46 | return nil, errors.New(fmt.Sprintf("can't find task '%s' in configuration", name)) 47 | } 48 | 49 | func Load(filename string) (*Settings, error) { 50 | content, err := ioutil.ReadFile(filename) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | cfg := &Settings{} 56 | err = yaml.Unmarshal(content, cfg) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return cfg, nil 61 | } 62 | 63 | func (m *Metric) UnmarshalYAML(unmarshal func(interface{}) error) error { 64 | type plain Metric 65 | 66 | // These are the default values for a basic metric config 67 | rawMetric := plain{ 68 | PeriodSeconds: 60, 69 | RangeSeconds: 600, 70 | DelaySeconds: 600, 71 | } 72 | if err := unmarshal(&rawMetric); err != nil { 73 | return err 74 | } 75 | 76 | *m = Metric(rawMetric) 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Technofy/cloudwatch_exporter 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.19.49 7 | github.com/beorn7/perks v1.0.0 8 | github.com/golang/protobuf v1.3.1 9 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af 10 | github.com/matttproud/golang_protobuf_extensions v1.0.1 11 | github.com/prometheus/client_golang v0.9.1 12 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 13 | github.com/prometheus/common v0.4.1 14 | github.com/prometheus/procfs v0.0.2 15 | gopkg.in/yaml.v2 v2.2.2 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 2 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 3 | github.com/aws/aws-sdk-go v1.19.49 h1:GUlenK625g5iKrIiRcqRS/CvPMLc8kZRtMxXuXBhFx4= 4 | github.com/aws/aws-sdk-go v1.19.49/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 5 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 6 | github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= 7 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 10 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 11 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 12 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 13 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 14 | github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= 15 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 16 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 17 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 18 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 19 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 20 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 21 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 22 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 23 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 24 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 27 | github.com/prometheus/client_golang v0.9.1 h1:K47Rk0v/fkEfwfQet2KWhscE0cJzjgCCDBG2KHZoVno= 28 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 29 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 30 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= 31 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 32 | github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= 33 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 34 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 35 | github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= 36 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 37 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 38 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 40 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 41 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 42 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 44 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 45 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 46 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 49 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 50 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 51 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/Technofy/cloudwatch_exporter/config" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | "os" 13 | "sync" 14 | ) 15 | 16 | var ( 17 | listenAddress = flag.String("web.listen-address", ":9042", "Address on which to expose metrics and web interface.") 18 | metricsPath = flag.String("web.telemetry-path", "/metrics", "Path under which to expose exporter's metrics.") 19 | scrapePath = flag.String("web.telemetry-scrape-path", "/scrape", "Path under which to expose CloudWatch metrics.") 20 | configFile = flag.String("config.file", "config.yml", "Path to configuration file.") 21 | 22 | globalRegistry *prometheus.Registry 23 | settings *config.Settings 24 | totalRequests prometheus.Counter 25 | configMutex = &sync.Mutex{} 26 | ) 27 | 28 | func loadConfigFile() error { 29 | var err error 30 | var tmpSettings *config.Settings 31 | configMutex.Lock() 32 | 33 | // Initial loading of the configuration file 34 | tmpSettings, err = config.Load(*configFile) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | generateTemplates(tmpSettings) 40 | 41 | settings = tmpSettings 42 | configMutex.Unlock() 43 | 44 | return nil 45 | } 46 | 47 | // handleReload handles a full reload of the configuration file and regenerates the collector templates. 48 | func handleReload(w http.ResponseWriter, req *http.Request) { 49 | err := loadConfigFile() 50 | if err != nil { 51 | str := fmt.Sprintf("Can't read configuration file: %s", err.Error()) 52 | fmt.Fprintln(w, str) 53 | fmt.Println(str) 54 | } 55 | fmt.Fprintln(w, "Reload complete") 56 | } 57 | 58 | // handleTarget handles scrape requests which make use of CloudWatch service 59 | func handleTarget(w http.ResponseWriter, req *http.Request) { 60 | urlQuery := req.URL.Query() 61 | 62 | target := urlQuery.Get("target") 63 | task := urlQuery.Get("task") 64 | region := urlQuery.Get("region") 65 | 66 | // Check if we have all the required parameters in the URL 67 | if task == "" { 68 | fmt.Fprintln(w, "Error: Missing task parameter") 69 | return 70 | } 71 | 72 | configMutex.Lock() 73 | registry := prometheus.NewRegistry() 74 | collector, err := NewCwCollector(target, task, region) 75 | if err != nil { 76 | // Can't create the collector, display error 77 | fmt.Fprintf(w, "Error: %s\n", err.Error()) 78 | configMutex.Unlock() 79 | return 80 | } 81 | 82 | registry.MustRegister(collector) 83 | handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{ 84 | DisableCompression: false, 85 | }) 86 | 87 | // Serve the answer through the Collect method of the Collector 88 | handler.ServeHTTP(w, req) 89 | configMutex.Unlock() 90 | } 91 | 92 | func main() { 93 | flag.Parse() 94 | 95 | globalRegistry = prometheus.NewRegistry() 96 | 97 | totalRequests = prometheus.NewCounter(prometheus.CounterOpts{ 98 | Name: "cloudwatch_requests_total", 99 | Help: "API requests made to CloudWatch", 100 | }) 101 | 102 | globalRegistry.MustRegister(totalRequests) 103 | 104 | prometheus.DefaultGatherer = globalRegistry 105 | 106 | err := loadConfigFile() 107 | if err != nil { 108 | fmt.Printf("Can't read configuration file: %s\n", err.Error()) 109 | os.Exit(-1) 110 | } 111 | 112 | fmt.Println("CloudWatch exporter started...") 113 | 114 | // Expose the exporter's own metrics on /metrics 115 | http.Handle(*metricsPath, promhttp.Handler()) 116 | 117 | // Expose CloudWatch through this endpoint 118 | http.HandleFunc(*scrapePath, handleTarget) 119 | 120 | // Allows manual reload of the configuration 121 | http.HandleFunc("/reload", handleReload) 122 | 123 | // Start serving for clients 124 | log.Fatal(http.ListenAndServe(*listenAddress, nil)) 125 | } 126 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | var ( 10 | sanitizeNameRegex, _ = regexp.Compile("[^a-zA-Z0-9:_]") 11 | mergeUScoreRegex, _ = regexp.Compile("__+") 12 | ) 13 | 14 | func safeName(dirty string) string { 15 | return mergeUScoreRegex.ReplaceAllString( 16 | sanitizeNameRegex.ReplaceAllString( 17 | strings.ToLower(dirty), "_"), 18 | "_") 19 | } 20 | 21 | func toSnakeCase(in string) string { 22 | runes := []rune(in) 23 | length := len(runes) 24 | 25 | var out []rune 26 | for i := 0; i < length; i++ { 27 | if i > 0 && unicode.IsUpper(runes[i]) && ((i+1 < length && unicode.IsLower(runes[i+1])) || unicode.IsLower(runes[i-1])) { 28 | out = append(out, '_') 29 | } 30 | out = append(out, unicode.ToLower(runes[i])) 31 | } 32 | 33 | return string(out) 34 | } 35 | --------------------------------------------------------------------------------