├── LICENSE ├── README.md ├── prometheus.bash └── test_prometheus.bash /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 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Typical usage: 2 | 3 | io::prometheus::NewGauge name=start_time help='When the run began' 4 | start_time set "$(date +%s)" 5 | io::prometheus::PushAdd job=cronjob instance='' gateway=:9091 6 | 7 | : main cron job code goes here 8 | 9 | io::prometheus::NewGauge name=end_time help='When the run ended' 10 | end_time set "$(date +%s)" 11 | io::prometheus::PushAdd job=cronjob instance='' gateway=:9091 12 | 13 | This is a library to help you push metrics from your Bash script to a 14 | [Prometheus pushgateway](https://github.com/prometheus/pushgateway) 15 | server. 16 | 17 | It's written to use [GNU Bash](http://www.gnu.org/software/bash/)'s features, 18 | mainly because the basic POSIX shell doesn't support the `local` keyword. 19 | 20 | The library is still missing some essential features such as documentation 21 | and non-gauge metrics. 22 | -------------------------------------------------------------------------------- /prometheus.bash: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Adrian Colley 2 | # Copying, adaptation and redistribution are permitted subject 3 | # to the terms in the accompanying LICENSE file (Apache 2.0). 4 | # 5 | # This is an attempt at a Prometheus Bash client library. 6 | # This is broadly based on the Go client library, but simplified. 7 | # All metrics are automatically registered at creation time. 8 | # Metric options don't have Namespace or Subsystem because shell scripts 9 | # can do the concatenation more clearly themselves. They don't have 10 | # ConstLabels because those are hard to construct while quoting everything 11 | # safely. Because all the metrics must have a distinct (fully-qualified) name, 12 | # their structures are all named after that name. 13 | 14 | # Notably missing from this script: 15 | # + User documentation 16 | # + Counter metrics 17 | # + Summary metrics 18 | # + A fallback if no "curl" is available (maybe /dev/tcp) 19 | # + Global constant labels (needed if library code is going to export any 20 | # vars without getting information from its client). 21 | 22 | # All variables defined in this file have names matching "io_prometheus_*". 23 | # Similarly, all functions have names matching "io::prometheus::*". 24 | 25 | # Example use: 26 | # io::prometheus::NewGauge name=start_time help='time_t when cron job last started' 27 | # start_time set $(date +'%s.%N') 28 | # io::prometheus::PushAdd job=cronjob instance=$HOSTNAME gateway=pushgateway0:9091 29 | 30 | # An example with labels (which doesn't work yet): 31 | # io::prometheus::NewGauge name=start_time help='time_t when cron job last started' \ 32 | # labels=host,runmode 33 | # start_time -host=spof0 -runmode=PRODUCTION set $(date +'%s.%N') 34 | # io::prometheus::PushAdd job=cronjob instance=$HOSTNAME gateway=pushgateway0:9091 35 | 36 | # Note to self: metric names match ^[a-zA-Z_:][a-zA-Z0-9_:]*$ and 37 | # label names match ^[a-zA-Z_][a-zA-Z0-9_]*$ according to 38 | # http://prometheus.io/docs/concepts/data_model/ 39 | 40 | # A list of all registered collectors (i.e. a list of function names). 41 | declare -a io_prometheus_registered_collectors 42 | 43 | # An associative array of values, used by the collectors defined in this file. 44 | # Each key is a metric name with its labels, in the client data exposition 45 | # format (i.e. \n, \" and \\ are escape sequences. Each value is the 46 | # corresponding metric value (a float in an unspecified format). 47 | # Example: 48 | # io_prometheus_value['start_time{host="spof0",runmode="PRODUCTION"}']=1.42342655e+09 49 | declare -A io_prometheus_value 50 | 51 | # An associative array of help strings, used by the collectors defined in this 52 | # file. Each key is a metric name without labels. Each value is the 53 | # corresponding metric help string in the client data exposition format (i.e. 54 | # \n and \\ are escape sequences). 55 | # Example: 56 | # io_prometheus_help[start_time]='time_t when cron job last started' 57 | declare -A io_prometheus_help 58 | 59 | # An associative array of metric types, used by the collectors defined in this 60 | # file. Each key is a metric name without labels. Each value is the 61 | # corresponding type as used following the TYPE keyword in the client data 62 | # exposition format. 63 | # Example: 64 | # io_prometheus_type[start_time]=gauge 65 | declare -A io_prometheus_type 66 | 67 | # An associative array of metric label sets associated with a metric name. 68 | # The order of the labels in this list is the same as their order in the 69 | # io_prometheus_value keys for the same metric name. This list is comma- 70 | # separated. Each value for a metric must have exactly the same label names 71 | # as in this list. This list includes all constant labels declared for 72 | # the exporter and the metric (but not "job" or "instance"). 73 | # Example: 74 | # io_prometheus_labelnames[start_time]=host,runmode 75 | declare -A io_prometheus_labelnames 76 | 77 | # Clear all the data saved in io_prometheus_* variables. 78 | # This is used by the unit tests; I can't think when else you'd use it. 79 | io::prometheus::DiscardAllMetrics() { 80 | local key 81 | unset io_prometheus_registered_collectors 82 | declare -a -g io_prometheus_registered_collectors 83 | unset io_prometheus_value 84 | declare -A -g io_prometheus_value 85 | unset io_prometheus_help 86 | declare -A -g io_prometheus_help 87 | unset io_prometheus_type 88 | declare -A -g io_prometheus_type 89 | unset io_prometheus_labelnames 90 | declare -A -g io_prometheus_labelnames 91 | } 92 | 93 | # This function outputs text compatible with version 0.0.4 of the 94 | # Prometheus client data exposition format as described at: 95 | # https://docs.google.com/document/d/1ZjyKiKxZV83VI9ZKAXRGKaUKK2BIWCT7oiGBKDBpjEY/view 96 | # It works by polling all of the registered collectors. 97 | # Args: none 98 | # Output: the text of all collectors (possibly corrupted unless status 0) 99 | # Return status: 0 if and only if every collector's collect method returns 0. 100 | io::prometheus::ExportAsText() { 101 | local collector retval=0 102 | for collector in "${io_prometheus_registered_collectors[@]}"; do 103 | ${collector} collect || { retval=$?; } 104 | done 105 | return ${retval} 106 | } 107 | 108 | io::prometheus::ExportToFile() { 109 | local filename="$1" 110 | local tmpfilename="$1.tmp.$$" 111 | io::prometheus::ExportAsText > "${tmpfilename}" \ 112 | && mv -f -- "${tmpfilename}" "${filename}" 113 | } 114 | 115 | # Create a new metric of type Gauge. 116 | io::prometheus::NewGauge() { 117 | local name='' help='' labels='' 118 | io::prometheus::internal::ParseDdStyleArgs "${FUNCNAME[0]}" \ 119 | 'name' 'help' '~labels' -- "$@" || return 120 | 121 | # Check syntax of the metric name and label names (and canonicalize them). 122 | io::prometheus::internal::CheckValidMetricName "${name}" || return 123 | local -a labelnames 124 | local savedIFS="$IFS" 125 | IFS=',' 126 | labelnames=(${labels}) 127 | labels="${labelnames[*]}" 128 | IFS="${savedIFS}" 129 | local label 130 | for label in "${labelnames[@]}"; do 131 | io::prometheus::internal::CheckValidLabelName "${label}" || return 132 | done 133 | 134 | # Warn about duplicate metric names. 135 | if [[ -n "${io_prometheus_type["${name}"]:-}" ]]; then 136 | io::prometheus::internal::PrintfError \ 137 | '"%s" is already a registered %s\n' \ 138 | "${name}" "${io_prometheus_type["${name}"]}" 139 | fi 140 | 141 | # Initialize the new gauge. 142 | io_prometheus_type["${name}"]=gauge 143 | local REPLY 144 | io::prometheus::internal::escape_help_string "${help}" 145 | io_prometheus_help["${name}"]="$REPLY" 146 | io_prometheus_labelnames["${name}"]="${labels}" 147 | if [[ -z "${labels}" ]]; then 148 | io_prometheus_value["${name}"]=0 149 | fi 150 | local dollar_at='"$@"' 151 | eval "${name}() { io::prometheus::internal::DispatchGauge ${name} ${dollar_at}; }" 152 | 153 | # Register it. 154 | io_prometheus_registered_collectors+=("${name}") 155 | } 156 | 157 | # Push the current values of registered metrics to a Prometheus pushgateway. 158 | # The newly-pushed metrics will replace any previously-pushed metrics with 159 | # the same (job, instance) pair. That is, it uses the PUT method. See PushAdd. 160 | # Args: 161 | # job=JOBVALUE - provides the mandatory "job" label name/value pair 162 | # instance=INSTANCEVALUE - provides the optional "instance" label pair 163 | # gateway=URL - address of the pushgateway 164 | # Output: none (error messages may appear on standard error) 165 | # Return status: 0 if and only if push successful. 166 | io::prometheus::Push() { 167 | io::prometheus::internal::Push method=PUT "$@" 168 | } 169 | 170 | # Push the current values of registered metrics to a Prometheus pushgateway. 171 | # The newly-pushed metrics will replace any previously-pushed metrics with 172 | # the same (, job, instance) pair. That is, it uses the POST 173 | # method. See also Push. 174 | # Args: 175 | # job=JOBVALUE - provides the mandatory "job" label name/value pair 176 | # instance=INSTANCEVALUE - provides the optional "instance" label pair 177 | # gateway=HOST:PORT - TCP address of the pushgateway 178 | # Output: none (error messages may appear on standard error) 179 | # Return status: 0 if and only if push successful. 180 | io::prometheus::PushAdd() { 181 | io::prometheus::internal::Push method=POST "$@" 182 | } 183 | 184 | io::prometheus::gauge::add() { 185 | local metric_name="$1" 186 | local num_labels="$2" 187 | shift 2 188 | 189 | # Combine the metric name and the label/value pairs into the series name. 190 | local REPLY 191 | io::prometheus::internal::assemble_series_name \ 192 | "${metric_name}" "${num_labels}" "$@" || return 193 | local series_name="${REPLY}" 194 | shift ${num_labels} 195 | 196 | # Now increase the current value of the named series by the supplied value. 197 | if [[ $# -ne 1 ]]; then 198 | io::prometheus::internal::PrintfError \ 199 | '"%s add" called with %s arguments (expected %s)\n' \ 200 | "${metric_name}" $# 1 201 | return 1 202 | fi 203 | io::prometheus::internal::Addition \ 204 | "${io_prometheus_value["${series_name}"]:-0}" "$1" || return 205 | io_prometheus_value["${series_name}"]="${REPLY}" 206 | return 0 207 | } 208 | 209 | io::prometheus::gauge::collect() { 210 | local metricname="$1" 211 | local -i num_label_args="$2" 212 | shift 2 213 | if [[ ${num_label_args} -ne 0 || $# -ne 0 ]]; then 214 | io::prometheus::internal::PrintfError \ 215 | '%s collect called with extra arguments "%s"\n' \ 216 | "${metricname}" "$*" 217 | return 1 218 | fi 219 | printf '# TYPE %s %s\n# HELP %s %s\n' \ 220 | "${metricname}" "${io_prometheus_type["${metricname}"]}" \ 221 | "${metricname}" "${io_prometheus_help["${metricname}"]}" || return 222 | local key 223 | for key in "${!io_prometheus_value[@]}"; do 224 | case "${key}" in 225 | "${metricname}"|"${metricname}{"*) 226 | printf '%s %s\n' "${key}" "${io_prometheus_value["${key}"]}" || return 227 | esac 228 | done 229 | return 0 230 | } 231 | 232 | io::prometheus::gauge::dec() { 233 | local metric_name="$1" 234 | local num_labels="$2" 235 | shift 2 236 | 237 | # Combine the metric name and the label/value pairs into the series name. 238 | local REPLY 239 | io::prometheus::internal::assemble_series_name \ 240 | "${metric_name}" "${num_labels}" "$@" || return 241 | local series_name="${REPLY}" 242 | shift ${num_labels} 243 | 244 | # Now decrease the current value of the named series by 1. 245 | if [[ $# -ne 0 ]]; then 246 | io::prometheus::internal::PrintfError \ 247 | '"%s dec" called with %s arguments (expected %s)\n' \ 248 | "${metric_name}" $# 0 249 | return 1 250 | fi 251 | io::prometheus::internal::Addition \ 252 | "${io_prometheus_value["${series_name}"]:-0}" -1 || return 253 | io_prometheus_value["${series_name}"]="${REPLY}" 254 | return 0 255 | } 256 | 257 | io::prometheus::gauge::inc() { 258 | local metric_name="$1" 259 | local num_labels="$2" 260 | shift 2 261 | 262 | # Combine the metric name and the label/value pairs into the series name. 263 | local REPLY 264 | io::prometheus::internal::assemble_series_name \ 265 | "${metric_name}" "${num_labels}" "$@" || return 266 | local series_name="${REPLY}" 267 | shift ${num_labels} 268 | 269 | # Now increase the current value of the named series by 1. 270 | if [[ $# -ne 0 ]]; then 271 | io::prometheus::internal::PrintfError \ 272 | '"%s inc" called with %s arguments (expected %s)\n' \ 273 | "${metric_name}" $# 0 274 | return 1 275 | fi 276 | io::prometheus::internal::Addition \ 277 | "${io_prometheus_value["${series_name}"]:-0}" 1 || return 278 | io_prometheus_value["${series_name}"]="${REPLY}" 279 | return 0 280 | } 281 | 282 | io::prometheus::gauge::set() { 283 | local metric_name="$1" 284 | local num_labels="$2" 285 | shift 2 286 | 287 | # Combine the metric name and the label/value pairs into the series name. 288 | local REPLY 289 | io::prometheus::internal::assemble_series_name \ 290 | "${metric_name}" "${num_labels}" "$@" || return 291 | local series_name="${REPLY}" 292 | shift ${num_labels} 293 | 294 | # Now set the current value of the named series to the supplied value. 295 | if [[ $# -ne 1 ]]; then 296 | io::prometheus::internal::PrintfError \ 297 | '"%s set" called with %s arguments (expected %s)\n' \ 298 | "${metric_name}" $# 1 299 | return 1 300 | fi 301 | # TODO(aecolley): Check that it's a parseable number assignable to float64. 302 | io_prometheus_value["${series_name}"]="$1" 303 | return 0 304 | } 305 | 306 | io::prometheus::gauge::setToElapsedTime() { 307 | local metric_name="$1" 308 | local num_labels="$2" 309 | shift 2 310 | 311 | # Combine the metric name and the label/value pairs into the series name. 312 | local REPLY 313 | io::prometheus::internal::assemble_series_name \ 314 | "${metric_name}" "${num_labels}" "$@" || return 315 | local series_name="${REPLY}" 316 | shift ${num_labels} 317 | 318 | # Check that we actually have a command to run. 319 | if [[ $# -lt 1 ]]; then 320 | io::prometheus::internal::PrintfError \ 321 | '"%s setToElapsedTime" called with %s arguments (expected %s+)\n' \ 322 | "${metric_name}" $# 1 323 | return 1 324 | fi 325 | local cmd="$1" 326 | shift 327 | 328 | local before after 329 | before="$( exec 2>&1; set -o posix; TIMEFORMAT='%3R'; time; )" 330 | 331 | local rc=0 332 | "${cmd}" "$@" 333 | rc=$? 334 | 335 | after="$( exec 2>&1; set -o posix; TIMEFORMAT='%3R'; time; )" 336 | 337 | local REPLY 338 | if io::prometheus::internal::Addition "${after}" -"${before}"; then 339 | if [[ "${REPLY}" =~ ^[0-9] ]]; then 340 | io_prometheus_value["${series_name}"]="${REPLY}" 341 | fi 342 | fi 343 | 344 | return ${rc} 345 | } 346 | 347 | io::prometheus::gauge::sub() { 348 | local metric_name="$1" 349 | local num_labels="$2" 350 | shift 2 351 | 352 | # Combine the metric name and the label/value pairs into the series name. 353 | local REPLY 354 | io::prometheus::internal::assemble_series_name \ 355 | "${metric_name}" "${num_labels}" "$@" || return 356 | local series_name="${REPLY}" 357 | shift ${num_labels} 358 | 359 | # Now decrease the current value of the named series by the supplied value. 360 | if [[ $# -ne 1 ]]; then 361 | io::prometheus::internal::PrintfError \ 362 | '"%s sub" called with %s arguments (expected %s)\n' \ 363 | "${metric_name}" $# 1 364 | return 1 365 | fi 366 | case "$1" in 367 | -*) 368 | io::prometheus::internal::Addition \ 369 | "${io_prometheus_value["${series_name}"]:-0}" "${1#'-'}" || return 370 | ;; 371 | *) 372 | io::prometheus::internal::Addition \ 373 | "${io_prometheus_value["${series_name}"]:-0}" "-$1" || return 374 | esac 375 | io_prometheus_value["${series_name}"]="${REPLY}" 376 | return 0 377 | } 378 | 379 | io::prometheus::internal::Addition() { 380 | local a="$1" 381 | local b="$2" 382 | if [[ "${a}${b}" =~ ^[-+0-9]*$ ]]; then 383 | # They're both integers; use builtin shell arithmetic. 384 | REPLY="$(( ${a} + ${b} ))" 385 | else 386 | # Too complex for bash, so use awk. 387 | local newvalue 388 | newvalue="$(awk -v a="${a}" -v b="${b}" 'BEGIN {print(a + b); exit(0)}')" \ 389 | || { 390 | io::prometheus::internal::PrintfError \ 391 | 'failed to compute (%s + %s) using awk\n' \ 392 | "${a}" "${b}" 393 | return 1 394 | } 395 | REPLY="${newvalue}" 396 | fi 397 | return 0 398 | } 399 | 400 | io::prometheus::internal::CheckValidLabelName() { 401 | local name="$1" 402 | if [[ "${name}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then 403 | return 0 404 | else 405 | io::prometheus::internal::PrintfError \ 406 | 'Malformed label name "%s" /%s/\n' \ 407 | "${name}" '^[a-zA-Z_][a-zA-Z0-9_]*$' 408 | return 1 409 | fi 410 | } 411 | 412 | io::prometheus::internal::CheckValidMetricName() { 413 | local name="$1" 414 | if [[ "${name}" =~ ^[a-zA-Z_:][a-zA-Z0-9_:]*$ ]]; then 415 | return 0 416 | else 417 | io::prometheus::internal::PrintfError \ 418 | 'Malformed metric name "%s" /%s/\n' \ 419 | "${name}" '^[a-zA-Z_:][a-zA-Z0-9_:]*$' 420 | return 1 421 | fi 422 | } 423 | 424 | io::prometheus::internal::DispatchGauge() { 425 | local metricname="$1" 426 | shift 427 | 428 | local -a label_args 429 | local methodname='' 430 | while [[ $# -gt 0 ]]; do 431 | case "$1" in 432 | -*=*) 433 | label_args+=("$1") 434 | shift 435 | ;; 436 | *) 437 | methodname="$1" 438 | shift 439 | break 440 | esac 441 | done 442 | 443 | case "${methodname}" in 444 | add|collect|dec|inc|set|setToElapsedTime|sub) 445 | io::prometheus::gauge::${methodname} \ 446 | "${metricname}" "${#label_args[@]}" "${label_args[@]}" "$@" 447 | ;; 448 | '') 449 | io::prometheus::internal::PrintfError 'Called %s without a method name\n' \ 450 | "${metricname}" 451 | return 1 452 | ;; 453 | *) 454 | io::prometheus::internal::PrintfError 'Gauge %s has no "%s" method\n' \ 455 | "${metricname}" "${methodname}" 456 | return 1 457 | esac 458 | } 459 | 460 | # For debugging. 461 | io::prometheus::internal::DumpInternalState() { 462 | local key 463 | printf 'io::prometheus::internal::DumpInternalState => {\n' 464 | printf ' io_prometheus_registered_collectors=%s\n' \ 465 | "${io_prometheus_registered_collectors[*]}" 466 | printf ' io_prometheus_value={\n' 467 | for key in "${!io_prometheus_value[@]}"; do 468 | printf ' [%s]=%s\n' "${key}" "${io_prometheus_value[${key}]}" 469 | done 470 | printf ' }\n' 471 | printf ' io_prometheus_help={\n' 472 | for key in "${!io_prometheus_help[@]}"; do 473 | printf ' [%s]=%s\n' "${key}" "${io_prometheus_help[${key}]}" 474 | done 475 | printf ' }\n' 476 | printf ' io_prometheus_type={\n' 477 | for key in "${!io_prometheus_type[@]}"; do 478 | printf ' [%s]=%s\n' "${key}" "${io_prometheus_type[${key}]}" 479 | done 480 | printf ' }\n' 481 | printf ' io_prometheus_labelnames={\n' 482 | for key in "${!io_prometheus_labelnames[@]}"; do 483 | printf ' [%s]=%s\n' "${key}" "${io_prometheus_labelnames[${key}]}" 484 | done 485 | printf ' }\n' 486 | printf '}\n' 487 | } 488 | 489 | io::prometheus::internal::DuplicateArg() { 490 | local funcname=$1 491 | local param=$2 492 | io::prometheus::internal::PrintfError \ 493 | 'Duplicate %s arg: %s\n' \ 494 | "${funcname}" "${param}" 495 | return 1 496 | } 497 | 498 | io::prometheus::internal::MissingArg() { 499 | local funcname=$1 500 | local param=$2 501 | io::prometheus::internal::PrintfError \ 502 | 'Missing %s arg: %s\n' \ 503 | "${funcname}" "${param}" 504 | return 1 505 | } 506 | 507 | # Example: io::prometheus::internal::ParseDdStyleArgs "${FUNCNAME[0]}" foo ~bar -- "$@" 508 | # The 0 is the number of stack frames to skip when generating error messages. 509 | # The ~ denotes an optional argument; all others are mandatory. 510 | # Returns 0 if parse successful and params assigned; 1 otherwise. 511 | io::prometheus::internal::ParseDdStyleArgs() { 512 | # All our local variables begin with parser_ to avoid accidental capture. 513 | local parser_funcname="$1"; shift 514 | local -A parser_params 515 | # parser_params' keys are parameter names; the values are: 516 | # mandatory - mandatory parameter not seen yet 517 | # optional - optional parameter not seen yet 518 | # already-seen - parameter seen already 519 | # Collect the params. 520 | while [[ $# -gt 0 ]]; do 521 | if [[ "$1" = "--" ]]; then 522 | shift; break 523 | elif [[ "$1" =~ ^[~] ]]; then 524 | parser_params["${1#'~'}"]=optional 525 | else 526 | parser_params["$1"]=mandatory 527 | fi 528 | shift 529 | done 530 | # Process the arguments and assign them. 531 | local parser_arg parser_val 532 | while [[ $# -gt 0 ]]; do 533 | if [[ "$1" =~ = ]]; then 534 | parser_arg="${1%%'='*}" 535 | parser_val="${1#*'='}" 536 | case "${parser_params["${parser_arg}"]:-unset}" in 537 | mandatory|optional) 538 | eval "${parser_arg}=\"\${parser_val}\"" 539 | parser_params["${parser_arg}"]=already-seen 540 | ;; 541 | already-seen) 542 | io::prometheus::internal::DuplicateArg "${parser_funcname}" "${parser_arg}" 543 | return 1 544 | ;; 545 | *) 546 | io::prometheus::internal::UnrecognizedArg "${parser_funcname}" "${parser_arg}" 547 | return 1 548 | esac 549 | else 550 | io::prometheus::internal::UnrecognizedArg "${parser_funcname}" "$1" 551 | return 1 552 | fi 553 | shift 554 | done 555 | # Complain about any mandatory arguments which weren't specified. 556 | for parser_arg in "${!parser_params[@]}"; do 557 | if [[ "${parser_params["${parser_arg}"]}" = "mandatory" ]]; then 558 | io::prometheus::internal::MissingArg "${parser_funcname}" "${parser_arg}" 559 | return 1 560 | fi 561 | done 562 | return 0 563 | } 564 | 565 | # Like printf 1>&2 except it prefixes the format with "ERROR: [file:line] " 566 | # where "file" and "line" are the address of the line that called into this 567 | # file of functions in the first place. 568 | io::prometheus::internal::PrintfError() { 569 | local format="$1" 570 | shift 571 | 572 | # Find the index in BASH_SOURCE of the innermost caller not in this file. 573 | local i=1 574 | while [[ $i -lt ${#BASH_SOURCE[@]} ]]; do 575 | if [[ "${BASH_SOURCE[$i]}" != "${BASH_SOURCE[0]}" ]]; then 576 | break 577 | fi 578 | i=$(( $i + 1 )) 579 | done 580 | if [[ $i -ge ${#BASH_SOURCE[@]} ]]; then 581 | i=1 # Didn't find one, here's a not-too-insane fallback value. 582 | fi 583 | 584 | local sourcefile="${BASH_SOURCE[${i}]}" 585 | i=$(( $i - 1 )) 586 | local sourceline="${BASH_LINENO[${i}]}" 587 | printf 1>&2 "ERROR: [%s:%s] ${format}" "${sourcefile}" "${sourceline}" "$@" 588 | } 589 | 590 | io::prometheus::internal::Push() { 591 | local method='' job='' instance='' gateway='' path='' 592 | io::prometheus::internal::ParseDdStyleArgs "${FUNCNAME[1]}" \ 593 | 'method' 'job' '~instance' 'gateway' '~path' -- "$@" || return 594 | 595 | # Construct the URL to push to. 596 | local url 597 | case "${gateway}" in 598 | http*) url="${gateway}/metrics/job/${job}";; 599 | :*) url="http://localhost${gateway}/metrics/job/${job}";; 600 | *:*) url="http://${gateway}/metrics/job/${job}";; 601 | *) url="http://${gateway}:9091/metrics/job/${job}" 602 | esac 603 | if [[ -n "${instance}" ]]; then 604 | url="${url}/instance/${instance}" 605 | fi 606 | if [[ -n "${path}" ]]; then 607 | url="${url}/${path}" 608 | fi 609 | # Compose and transmit the metrics. 610 | io::prometheus::ExportAsText | curl -q \ 611 | --request "${method}" \ 612 | --data-binary '@-' \ 613 | --user-agent 'Prometheus-client_bash/prerelease' \ 614 | --header 'Content-Type: text/plain; version=0.0.4' \ 615 | --fail \ 616 | --silent \ 617 | --connect-timeout 5 \ 618 | --max-time 10 \ 619 | "${url}" > /dev/null 620 | 621 | [[ "${PIPESTATUS[0]}" -eq 0 && "${PIPESTATUS[1]}" -eq 0 ]] 622 | } 623 | 624 | io::prometheus::internal::UnrecognizedArg() { 625 | local funcname=$1 626 | local arg=$2 627 | io::prometheus::internal::PrintfError \ 628 | 'Unrecognized %s arg: %s\n' \ 629 | "${funcname}" "${arg}" 630 | return 1 631 | } 632 | 633 | # Sets the variable REPLY to the variable's key in io_prometheus_value. 634 | # Argument 1 is the name of the metric. 635 | # Argument 2 is the number of flag arguments (num_flags). 636 | # Arguments 3..(3+num_flags-1) are the flag arguments 637 | # Arguments (3+num_flags)..($#) are ignored. 638 | # If an error occurs, REPLY is set to the error message and this returns 1. 639 | # 640 | # Example: 641 | # Given: 642 | # io_prometheus_labelnames[horses]=name,number 643 | # the call: 644 | # io::prometheus::internal::assemble_series_name \ 645 | # 'horses' 2 -number=1 -name="Zeinab Badawi's Twenty Hotels" to look for 646 | # will set REPLY to the string: 647 | # horses{name="Zeinab Badawi's Twenty Hotels",number="1"} 648 | io::prometheus::internal::assemble_series_name() { 649 | local metric_name="$1" 650 | local num_flags="$2" 651 | shift 2 652 | 653 | # Put the first $num_flags arguments into an associative array. 654 | local -A labelmap 655 | local key_equals_value key value 656 | local num_labels_shifted=0 657 | while [[ ${num_labels_shifted} -lt ${num_flags} ]]; do 658 | key_equals_value="${1#'-'}" 659 | shift 660 | num_labels_shifted=$((num_labels_shifted + 1)) 661 | key="${key_equals_value%%'='*}" 662 | value="${key_equals_value#*'='}" 663 | if [[ -n "${labelmap["${key}"]+set}" ]]; then 664 | REPLY="Label ${key} is assigned twice" 665 | return 1 666 | fi 667 | labelmap["${key}"]="${value}" 668 | done 669 | 670 | # Now take them out again in canonical order, building up series_name. 671 | local series_name='' num_labels_encoded=0 672 | local csv="${io_prometheus_labelnames["${metric_name}"]-'???'}" 673 | if [[ "${csv}" = '???' ]]; then 674 | REPLY="No such metric: ${metric_name}" 675 | return 1 676 | fi 677 | while [[ -n "${csv}" ]]; do 678 | # Shift the first comma-separated item off the list. 679 | case "${csv}" in 680 | *','*) 681 | key="${csv%%','*}" 682 | csv="${csv#*','}" 683 | ;; 684 | *) 685 | key="${csv}" 686 | csv='' 687 | esac 688 | if [[ -z "${labelmap["${key}"]+set}" ]]; then 689 | REPLY="No value supplied for ${metric_name} label ${key}" 690 | return 1 691 | fi 692 | value="${labelmap["${key}"]}" 693 | io::prometheus::internal::escape_label_value "${value}" # sets REPLY 694 | series_name="${series_name}${series_name:+,}${key}=\"${REPLY}\"" 695 | num_labels_encoded=$(( $num_labels_encoded + 1 )) 696 | done 697 | if [[ "${num_labels_encoded}" != "${num_labels_shifted}" ]]; then 698 | REPLY="Incorrect number of labels for ${metric_name} (expected ${num_labels_encoded}, actual ${num_labels_shifted})" 699 | return 1 700 | fi 701 | 702 | # Assemble the whole series name. 703 | if [[ -n "${series_name}" ]]; then 704 | REPLY="${metric_name}{${series_name}}" 705 | else 706 | REPLY="${metric_name}" 707 | fi 708 | return 0 709 | } 710 | 711 | # Assigns to REPLY the value of $1, with escaping as follows: 712 | # each newline is replaced with '\n'; 713 | # each backslash is replaced with two backslashes. 714 | io::prometheus::internal::escape_help_string() { 715 | local input="$1" 716 | local left backslash='\' newline=' 717 | ' 718 | REPLY='' 719 | while true; do 720 | local previnput="${input}" 721 | case "${input}" in 722 | "${newline}"*) 723 | REPLY="${REPLY}${backslash}"n 724 | input="${input#"${newline}"}" 725 | ;; 726 | "${backslash}"*) 727 | REPLY="${REPLY}${backslash}${backslash}" 728 | input="${input#"${backslash}"}" 729 | ;; 730 | *["${newline}${backslash}"]*) 731 | left="${input%%["${newline}${backslash}"]*}" 732 | REPLY="${REPLY}${left}" 733 | input="${input#"${left}"}" 734 | ;; 735 | *) 736 | REPLY="${REPLY}${input}" 737 | return 0 738 | esac 739 | if [[ "${previnput}" = "${input}" ]]; then 740 | printf 1>&2 'escape_help_string failed to reduce "%s"\n' "${input}" 741 | exit 1 742 | fi 743 | done 744 | } 745 | 746 | # Assigns to REPLY the value of $1, with escaping as follows: 747 | # each newline is replaced with '\n'; 748 | # each double-quote is replaced with a backslash-double-quote pair; and 749 | # each backslash is replaced with two backslashes. 750 | io::prometheus::internal::escape_label_value() { 751 | local input="$1" 752 | local left backslash='\' doublequote='"' newline=' 753 | ' 754 | REPLY='' 755 | while true; do 756 | case "${input}" in 757 | "${newline}"*) 758 | REPLY="${REPLY}${backslash}"n 759 | input="${input#"${newline}"}" 760 | ;; 761 | "${doublequote}"*) 762 | REPLY="${REPLY}${backslash}${doublequote}" 763 | input="${input#"${doublequote}"}" 764 | ;; 765 | "${backslash}"*) 766 | REPLY="${REPLY}${backslash}${backslash}" 767 | input="${input#"${backslash}"}" 768 | ;; 769 | *["${newline}${doublequote}${backslash}"]*) 770 | left="${input%%["${newline}${doublequote}${backslash}"]*}" 771 | REPLY="${REPLY}${left}" 772 | input="${input#"${left}"}" 773 | ;; 774 | *) 775 | REPLY="${REPLY}${input}" 776 | return 0 777 | esac 778 | done 779 | } 780 | 781 | -------------------------------------------------------------------------------- /test_prometheus.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . prometheus.bash 4 | 5 | test_simple() { 6 | io::prometheus::DiscardAllMetrics 7 | io::prometheus::NewGauge name=start_time help='time_t when something started' || return 8 | io::prometheus::NewGauge name=end_time help='time_t when something ended' || return 9 | start_time set 1428782596 || return 10 | sleep 1 11 | end_time set 1428782597 || return 12 | io::prometheus::ExportToFile "/tmp/prometheus_actual_test_simple.$$" || return 13 | local rc=0 14 | diff -u - "/tmp/prometheus_actual_test_simple.$$" <<'TEST_EXPECTED_EOF' || rc=$? 15 | # TYPE start_time gauge 16 | # HELP start_time time_t when something started 17 | start_time 1428782596 18 | # TYPE end_time gauge 19 | # HELP end_time time_t when something ended 20 | end_time 1428782597 21 | TEST_EXPECTED_EOF 22 | rm -f "/tmp/prometheus_actual_test_simple.$$" 23 | return ${rc} 24 | } 25 | 26 | # According to the client data exposition format, any leading whitespace in 27 | # the help string will be skipped on parsing. Still, it's harmless. 28 | test_weird_help() { 29 | io::prometheus::DiscardAllMetrics 30 | io::prometheus::NewGauge name=mojibake help=' ALL Y"OUR B\ASE 31 | ARE BEL'\''ONG TO US' || return 32 | mojibake set 23 || return 33 | io::prometheus::ExportToFile "/tmp/prometheus_actual_test_weird_help$$" || return 34 | local rc=0 35 | diff -u - "/tmp/prometheus_actual_test_weird_help$$" <<'TEST_EXPECTED_EOF' || rc=$? 36 | # TYPE mojibake gauge 37 | # HELP mojibake ALL Y"OUR B\\ASE\nARE BEL'ONG TO US 38 | mojibake 23 39 | TEST_EXPECTED_EOF 40 | rm -f "/tmp/prometheus_actual_test_weird_help$$" 41 | return ${rc} 42 | } 43 | 44 | test_labels() { 45 | io::prometheus::DiscardAllMetrics 46 | io::prometheus::NewGauge name=falling_speed labels=faller \ 47 | help='meters per second (terminal velocity)' || return 48 | falling_speed -faller=GLaDOS set 300 || return 49 | falling_speed -faller=Chell set 301 || return 50 | io::prometheus::ExportToFile "/tmp/prometheus_actual_test_labels.$$" || return 51 | LC_COLLATE=C sort -o "/tmp/prometheus_actual_test_labels.$$" \ 52 | "/tmp/prometheus_actual_test_labels.$$" || return 53 | local rc=0 54 | diff -u - "/tmp/prometheus_actual_test_labels.$$" <<'TEST_EXPECTED_EOF' || rc=$? 55 | # HELP falling_speed meters per second (terminal velocity) 56 | # TYPE falling_speed gauge 57 | falling_speed{faller="Chell"} 301 58 | falling_speed{faller="GLaDOS"} 300 59 | TEST_EXPECTED_EOF 60 | rm -f "/tmp/prometheus_actual_test_labels.$$" 61 | return ${rc} 62 | } 63 | 64 | test_weird_labels() { 65 | io::prometheus::DiscardAllMetrics 66 | io::prometheus::NewGauge name=alien_heart_count labels=species \ 67 | help='vivisection results' || return 68 | alien_heart_count -species="'uman" set 1 || return 69 | alien_heart_count -species='Vl"hurg' set 7 || return 70 | alien_heart_count -species='Gallifreyan 71 | ' set 2 || return 72 | alien_heart_count -species='Cent\ari' set 2 || return 73 | local unicode_snowman='☃' # U+2603 74 | alien_heart_count -species="${unicode_snowman}" set 0 || return 75 | io::prometheus::ExportToFile "/tmp/prometheus_actual_test_weird_labels.$$" || return 76 | LC_COLLATE=C sort -o "/tmp/prometheus_actual_test_weird_labels.$$" \ 77 | "/tmp/prometheus_actual_test_weird_labels.$$" || return 78 | local rc=0 79 | diff -u - "/tmp/prometheus_actual_test_weird_labels.$$" <<'TEST_EXPECTED_EOF' || rc=$? 80 | # HELP alien_heart_count vivisection results 81 | # TYPE alien_heart_count gauge 82 | alien_heart_count{species="'uman"} 1 83 | alien_heart_count{species="Cent\\ari"} 2 84 | alien_heart_count{species="Gallifreyan\n"} 2 85 | alien_heart_count{species="Vl\"hurg"} 7 86 | alien_heart_count{species="☃"} 0 87 | TEST_EXPECTED_EOF 88 | rm -f "/tmp/prometheus_actual_test_weird_labels.$$" 89 | return ${rc} 90 | } 91 | 92 | test_setToElapsedTime() { 93 | io::prometheus::DiscardAllMetrics 94 | io::prometheus::NewGauge name=duration help='Seconds "sleep 2" took to run' 95 | duration setToElapsedTime sleep 2 || return 96 | local collection savedDuration 97 | collection="$(duration collect)" || return 98 | savedDuration="$(printf '%s\n' "${collection}" | sed -n -e 's/^duration //p')" 99 | case "${savedDuration}" in 100 | 1.9*) : close enough ;; 101 | 2) : right on ;; 102 | 2.0*) : close enough ;; 103 | *) 104 | printf 1>&2 'Expected "duration 2" but got:\n%s\n' "${collection}" 105 | return 1 106 | esac 107 | return 0 108 | } 109 | 110 | alltests() { 111 | test_simple || return 112 | test_weird_help || return 113 | test_labels || return 114 | test_weird_labels || return 115 | test_setToElapsedTime || return 116 | } 117 | 118 | main() { 119 | local rc=0 120 | alltests || { rc=$?; } 121 | if [[ ${rc} -ne 0 ]]; then 122 | io::prometheus::internal::DumpInternalState 1>&2 123 | else 124 | printf 'OK\n' 125 | fi 126 | return ${rc} 127 | } 128 | 129 | main "$@" 130 | --------------------------------------------------------------------------------