├── .gitattributes ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── NOTICE ├── README.md ├── examples ├── .gitignore ├── shard.yml └── src │ ├── custom-server.cr │ └── wordcounter.cr ├── shard.yml ├── spec ├── counter_spec.cr ├── gauge_spec.cr ├── histogram_spec.cr ├── metric_spec.cr ├── middleware │ └── http_collector_spec.cr ├── proc │ ├── 26231 │ │ ├── fd │ │ │ ├── 0 │ │ │ ├── 1 │ │ │ ├── 2 │ │ │ ├── 3 │ │ │ └── 4 │ │ ├── limits │ │ └── stat │ └── stat ├── registry_spec.cr ├── spec_helper.cr ├── standard_exports_spec.cr └── summary_spec.cr └── src ├── crometheus.cr └── crometheus ├── counter.cr ├── exceptions.cr ├── gauge.cr ├── histogram.cr ├── metric.cr ├── middleware └── http_collector.cr ├── registry.cr ├── sample.cr ├── standard_exports.cr ├── stringify.cr ├── summary.cr └── version.cr /.gitattributes: -------------------------------------------------------------------------------- 1 | # Temporary until Rouge supports Crystal 2 | *.cr gitlab-language=ruby 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | 3 | script: 4 | - crystal spec 5 | - crystal tool format --check 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## **0.3.1** - 2020-06-23 2 | * Updated for Crystal 0.35.1 3 | 4 | ## **0.3.0** - 2020-05-10 5 | * Patched to work with Crystal 0.34.0 6 | 7 | ## **0.2.0** - 2017-10-02 8 | * New: `Crometheus::Middleware::HttpCollector` allows easy HTTP metric 9 | gathering. 10 | * Changed: Metric names now implicitly add an underscore before the 11 | suffix, if present. 12 | * New: `Registry#path` specifies the HTTP request path(s) on which to 13 | serve metrics. 14 | * New: `Registry#handler` returns an `HTTP::Handler` object. 15 | * New: `Registry` by default creates a `StandardExports` (or derived) 16 | metric for exporting process statistics. 17 | * New: `Crometheus.alias` allows shorthand aliasing of `LabeledMetric` 18 | types 19 | 20 | ## **0.1.1** - 2017-02-06 21 | * Initial release 22 | * Includes Gauges, Counters, Summaries, and Histograms 23 | * Includes Registry class with basic server functionality 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Crometheus - a Prometheus instrumentation library for Crystal applications 2 | Copyright 2017 Ezra Stevens 3 | Copyright 2020 Darwin 4 | 5 | The specification suite for this library includes files taken from the official 6 | Prometheus Python client library, which is available under the "Apache-2.0" 7 | license. 8 | Files under spec/proc are Copyright 2015 by The Prometheus Authors. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Darwinnn/crometheus.svg?branch=master)](https://travis-ci.org/Darwinnn/crometheus) 2 | # crometheus 3 | 4 | This a github fork of ezrast's [Crometheus](https://gitlab.com/ezrast/crometheus) with patches that allow it to work with the latest Crystal version (1.4 for now) 5 | 6 | [Crometheus](https://gitlab.com/ezrast/crometheus) is a [Prometheus](https://prometheus.io/) client library for instrumenting programs written in the [Crystal programming language](https://crystal-lang.org/). 7 | For the most part, Crometheus assumes a basic familiarity with Prometheus. 8 | To that end, readers may wish to skim the official documentation on Prometheus' [data model](https://prometheus.io/docs/concepts/data_model/), [metric types](https://prometheus.io/docs/concepts/metric_types/), and [text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/#text-format-details). 9 | 10 | Crometheus is in early development and comes with no guarantees. This project is not affiliated with or endorsed by Prometheus. 11 | 12 | For latest updates, see [CHANGELOG.md](CHANGELOG.md). 13 | 14 | ## Installation 15 | 16 | Add this to your application's `shard.yml`: 17 | 18 | ```yaml 19 | dependencies: 20 | crometheus: 21 | github: darwinnn/crometheus 22 | branch: master 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```crystal 28 | require "crometheus" 29 | 30 | # Create an unlabeled summary. 31 | summary = Crometheus::Summary.new( 32 | :my_first_summary, 33 | "A sample summary metric") 34 | 35 | # Observe some values. 36 | summary.observe 100 37 | summary.observe 200.0 38 | 39 | # Create a gauge with labels "foo" and "bar". 40 | gauge = Crometheus::Gauge[:foo, :bar].new( 41 | :my_first_gauge, 42 | "A sample gauge metric, with labels") 43 | 44 | # In some cases the above syntax will cause type inference to fail; 45 | # work around it with the `Crometheus.alias` macro like this. 46 | Crometheus.alias WidgetCounter = Crometheus::Counter[:kind] 47 | widget_counter = WidgetCounter.new( 48 | :widgets_made, 49 | "Number of widgets produced") 50 | 51 | # Set some values. 52 | gauge[foo: "Hello", bar: "Anthony"].set 3.14159 53 | gauge[foo: "Goodbye", bar: "Clarice"].set -8e12 54 | widget_counter[kind: "sprocket"].inc 55 | 7.times{ widget_counter[kind: "pinion"].inc } 56 | 57 | # Access the default registry and start up the server. 58 | Crometheus.default_registry.run_server 59 | ``` 60 | Then visit [http://localhost:5000](http://localhost:5000) to see your 61 | metrics (you may see some default process metrics as well): 62 | ```text 63 | # HELP my_first_gauge A sample gauge metric, with labels 64 | # TYPE my_first_gauge gauge 65 | my_first_gauge{foo="Hello", bar="Anthony"} 3.14159 66 | my_first_gauge{foo="Goodbye", bar="Clarice"} -8000000000000.0 67 | # HELP my_first_summary A sample summary metric 68 | # TYPE my_first_summary summary 69 | my_first_summary_count 2.0 70 | my_first_summary_sum 300.0 71 | # HELP widgets_made Number of widgets produced 72 | # TYPE widgets_made counter 73 | widgets_made{kind="sprocket"} 1.0 74 | widgets_made{kind="pinion"} 7.0 75 | ``` 76 | 77 | The above is all the setup you need for straightforward use cases; all that's left is creating real metrics and instrumenting your code. 78 | See the reference documentation for the [Gauge](https://ezrast.gitlab.io/crometheus/Crometheus/Gauge.html), [Counter](https://ezrast.gitlab.io/crometheus/Crometheus/Counter.html), [Histogram](https://ezrast.gitlab.io/crometheus/Crometheus/Histogram.html), and [Summary](https://ezrast.gitlab.io/crometheus/Crometheus/Summary.html) classes to learn more about the available metric types. 79 | The bracket notation `Gauge[:foo, :bar]` in the example above is a bit of macro magic that creates a [LabeledMetric](https://ezrast.gitlab.io/crometheus/Crometheus/Metric/LabeledMetric.html) with `Gauge` as a type parameter. 80 | See the `examples` directory for more samples. 81 | 82 | For server configuration see the [Registry](https://ezrast.gitlab.io/crometheus/Crometheus/Registry.html) class documentation. 83 | If you want to use multiple registries, e.g. to expose two different sets of metrics on different ports, you'll need to instantiate a second Registry object (other than the default) and pass it as a third argument to your metric constructors (after the name and docstring). 84 | 85 | If you want to define a custom metric type, see the documentation for the [Metric](https://ezrast.gitlab.io/crometheus/Crometheus/Metric.html) class, and inherit from that. 86 | 87 | Alternately, you can just dive into the [API Documentation](https://ezrast.gitlab.io/crometheus) right from the top. 88 | 89 | ## Contributing 90 | 91 | 1. Fork it 92 | 2. Create your feature branch (git checkout -b my-new-feature) 93 | 3. Commit your changes (git commit -am 'Add some feature') 94 | 4. Push to the branch (git push origin my-new-feature) 95 | 5. Create a new Merge Request 96 | 97 | ## Author 98 | 99 | - [Ezra Stevens](https://gitlab.com/ezrast) - original author 100 | - [Darwin](https://github.com/darwinnn) - github fork and patches for Crystal 0.34.0 101 | 102 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | shard.lock 2 | lib/crometheus 3 | -------------------------------------------------------------------------------- /examples/shard.yml: -------------------------------------------------------------------------------- 1 | name: examples 2 | version: 0.3.0 3 | 4 | authors: 5 | - Ezra Stevens 6 | - Darwin 7 | 8 | targets: 9 | wordcounter: 10 | main: src/wordcounter.cr 11 | custom-server: 12 | main: src/custom-server.cr 13 | 14 | dependencies: 15 | crometheus: 16 | path: ../ 17 | 18 | crystal: 0.35.1 19 | 20 | license: Apache 2.0 21 | -------------------------------------------------------------------------------- /examples/src/custom-server.cr: -------------------------------------------------------------------------------- 1 | # An example that shows how to use a registry's HTTP handler in tandem 2 | # with Crystal's HTTP features. 3 | # Projects of any scale will usually build their own handler stack like 4 | # this, rather than rely on the built-in start_server or run_server 5 | # methods. 6 | require "http/server" 7 | require "http/server/handlers/compress_handler" 8 | require "crometheus" 9 | 10 | metrics_handler = Crometheus.default_registry.get_handler 11 | Crometheus.default_registry.path = "/metrics" 12 | summary = Crometheus::Summary.new(:manual_values, "values entered via web ui") 13 | 14 | server = HTTP::Server.new([HTTP::CompressHandler.new, 15 | HTTP::LogHandler.new, 16 | HTTP::ErrorHandler.new(true), 17 | Crometheus::Middleware::HttpCollector.new, 18 | metrics_handler]) do |context| 19 | if "/" == context.request.path 20 | if val = context.request.body.try &.gets_to_end 21 | val =~ /value=(.+)/ 22 | summary.observe $1.to_f 23 | message = "Observed #{$1.to_f}
" 24 | end 25 | context.response << MAIN_HTML % message 26 | else 27 | context.response.status_code = 404 28 | context.response << ERROR_HTML % context.request.path 29 | end 30 | end 31 | 32 | address = server.bind_tcp "localhost", 3000 33 | puts "Launching server at http://#{address}" 34 | puts "Press Ctrl+C to exit" 35 | server.listen 36 | 37 | ##### 38 | 39 | MAIN_HTML = <<-HTML 40 | 41 | %s 42 |
Type a numeric value to observe that value in a histogram. 43 |
Type anything else to cause an exception. 44 |
45 | 46 | 47 |
48 |
49 | See metrics 50 | 51 | HTML 52 | 53 | ERROR_HTML = <<-HTML 54 | 55 | No resource at %s. 56 | You can go home or see metrics. 57 | 58 | HTML 59 | -------------------------------------------------------------------------------- /examples/src/wordcounter.cr: -------------------------------------------------------------------------------- 1 | # A simplistic usage example for basic Crometheus features. 2 | # Creates some metrics, starts a server with default settings, and 3 | # measures some silly statistics based on user input. 4 | require "crometheus/counter" 5 | require "crometheus/gauge" 6 | require "crometheus/histogram" 7 | include Crometheus 8 | 9 | class WordCounter 10 | # Alias a labeled histogram type with a single label 11 | Crometheus.alias WordLengths = Histogram[:kind] 12 | 13 | def initialize 14 | # Initialize some new unlabelled metrics, passing a name and 15 | # docstring to each 16 | @lines = Counter.new(:lines, "The number of lines entered") 17 | @last_usage = Gauge.new(:last_usage, "Timestamp of latest gets()") 18 | @last_usage.set_to_current_time 19 | 20 | # Helper method for creating bucket arrays for histograms; simply 21 | # generates [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, +Inf] 22 | bucket_array = Histogram.linear_buckets(4, 1, 7) 23 | 24 | # Histograms take a "buckets" argument in addition to name and 25 | # docstring. 26 | @word_length = WordLengths.new(:word_length, "How many words have been typed", 27 | buckets: bucket_array) 28 | 29 | # Accessing a particular labelset forces it to be initialized to 0. 30 | # Since we know all the label values we'll be using in advance, 31 | # we'll initialize them; this is optional. 32 | ["", "allcaps", "punctuated", "palindrome"].each do |label| 33 | @word_length[kind: label] 34 | end 35 | end 36 | 37 | def count_words(line : String) 38 | @lines.inc 39 | @last_usage.set_to_current_time 40 | line.scan(/\b\S+\b/) do |match| 41 | word = match[0] 42 | length = word.chars.count &.alphanumeric? 43 | @word_length[kind: ""].observe(length) 44 | @word_length[kind: "palindrome"].observe(length) if word == word.reverse 45 | @word_length[kind: "allcaps"].observe(length) if word == word.upcase 46 | @word_length[kind: "punctuated"].observe(length) unless word.chars.all? &.alphanumeric? 47 | end 48 | end 49 | end 50 | 51 | # By default, all metrics are added to a default registry, accessible 52 | # like this. 53 | reg = Crometheus.default_registry 54 | reg.namespace = "wordcounter" 55 | # Fire up the HTTP server in the background. 56 | reg.start_server 57 | 58 | word_counter = WordCounter.new 59 | 60 | puts "Type a line of text, then visit http://#{reg.host}:#{reg.port}." 61 | puts "Press Ctrl+D to quit." 62 | while true 63 | line = gets 64 | if line.nil? 65 | break 66 | end 67 | word_counter.count_words(line) 68 | end 69 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crometheus 2 | version: 0.3.0 3 | 4 | authors: 5 | - Ezra Stevens 6 | - Darwin 7 | 8 | crystal: 0.35.1 9 | 10 | license: Apache 2.0 11 | -------------------------------------------------------------------------------- /spec/counter_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/counter" 3 | 4 | describe Crometheus::Counter do 5 | it "defaults to 0.0" do 6 | counter = Crometheus::Counter.new(:x, "", nil) 7 | counter.get.should eq 0.0 8 | end 9 | 10 | describe "#inc" do 11 | it "increments the value" do 12 | counter = Crometheus::Counter.new(:x, "", nil) 13 | counter.inc 14 | counter.get.should eq 1.0 15 | counter.inc 9.0 16 | counter.get.should eq 10.0 17 | end 18 | 19 | it "raises on negative numbers" do 20 | expect_raises(ArgumentError) do 21 | Crometheus::Counter.new(:x, "", nil).inc -1.0 22 | end 23 | end 24 | end 25 | 26 | describe "#reset" do 27 | it "resets the value to zero" do 28 | counter = Crometheus::Counter.new(:x, "", nil) 29 | counter.inc 30 | counter.reset 31 | counter.get.should eq 0.0 32 | end 33 | end 34 | 35 | describe "#count_exceptions" do 36 | it "increments when the block raises an exception" do 37 | counter = Crometheus::Counter.new(:x, "", nil) 38 | 10.times do |ii| 39 | begin 40 | counter.count_exceptions { raise CrometheusTestException.new if ii % 2 == 0 } 41 | rescue ex : CrometheusTestException 42 | end 43 | end 44 | counter.get.should eq 5.0 45 | end 46 | 47 | it "re-raises the exception" do 48 | expect_raises(CrometheusTestException) do 49 | Crometheus::Counter.new(:x, "", nil).count_exceptions { 50 | raise CrometheusTestException.new 51 | } 52 | end 53 | end 54 | end 55 | 56 | describe ".count_exceptions_of_type" do 57 | it "increment when the block raises the given type of exception" do 58 | counter = Crometheus::Counter.new(:x, "", nil) 59 | exceptions = [CrometheusTestException.new, KeyError.new, DivisionByZeroError.new, 60 | CrometheusTestException.new, CrometheusTestException.new] 61 | exceptions.each do |ex| 62 | expect_raises(ex.class) do 63 | Crometheus::Counter.count_exceptions_of_type(counter, CrometheusTestException) { raise ex } 64 | end 65 | end 66 | counter.get.should eq 3.0 67 | end 68 | end 69 | 70 | describe "#samples" do 71 | it "returns an appropriate Array of Samples" do 72 | counter = Crometheus::Counter.new(:x, "", nil) 73 | counter.inc(10) 74 | get_samples(counter).should eq [Crometheus::Sample.new(10.0)] 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/gauge_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/gauge" 3 | 4 | describe Crometheus::Gauge do 5 | it "defaults new gauges to 0.0" do 6 | Crometheus::Gauge.new(:x, "", nil).get.should eq 0.0 7 | end 8 | 9 | describe "#set and #get" do 10 | it "sets and gets the metric value" do 11 | gauge = Crometheus::Gauge.new(:x, "", nil) 12 | gauge.set(23) 13 | gauge.get.should eq(23.0) 14 | gauge.set(24.0f32) 15 | gauge.get.should eq(24.0) 16 | gauge.set(25.0) 17 | gauge.get.should eq(25.0) 18 | end 19 | end 20 | 21 | describe "#inc" do 22 | it "increments the value" do 23 | gauge = Crometheus::Gauge.new(:x, "", nil) 24 | gauge.set 20.0 25 | gauge.inc 26 | gauge.get.should eq 21.0 27 | gauge.inc 9.0 28 | gauge.get.should eq 30.0 29 | end 30 | end 31 | 32 | describe "#dec" do 33 | it "decrements the value" do 34 | gauge = Crometheus::Gauge.new(:x, "", nil) 35 | gauge.set 20.0 36 | gauge.dec 37 | gauge.get.should eq 19.0 38 | gauge.dec 4.0 39 | gauge.get.should eq 15.0 40 | end 41 | end 42 | 43 | describe "#set_to_current_time" do 44 | it "sets the gauge to the current UNIX timestamp" do 45 | gauge = Crometheus::Gauge.new(:x, "", nil) 46 | gauge.set_to_current_time 47 | (1484901416..4102444800).should contain gauge.get 48 | end 49 | end 50 | 51 | describe "#measure_runtime" do 52 | it "sets the gauge to the runtime of a block" do 53 | gauge = Crometheus::Gauge.new(:x, "", nil) 54 | gauge.measure_runtime { sleep 0.4 } 55 | (0.35..0.45).should contain gauge.get 56 | end 57 | 58 | it "works even when exceptions are raised" do 59 | gauge = Crometheus::Gauge.new(:x, "", nil) 60 | gauge.set 0.0 61 | expect_raises(CrometheusTestException) do 62 | gauge.measure_runtime { sleep 0.2; raise CrometheusTestException.new } 63 | end 64 | (0.2..0.25).should contain gauge.get 65 | end 66 | end 67 | 68 | describe "#count_concurrent" do 69 | it "increases the gauge while a block is running" do 70 | gauge = Crometheus::Gauge.new(:x, "", nil) 71 | counted_sleep = ->(duration : Float64) { 72 | gauge.count_concurrent { sleep duration } 73 | } 74 | 75 | gauge.set 0.0 76 | [0.2, 0.4, 0.6].each { |duration| spawn { counted_sleep.call duration } } 77 | sleep 0.1 78 | gauge.get.should eq 3.0 79 | sleep 0.2 80 | gauge.get.should eq 2.0 81 | sleep 0.2 82 | gauge.get.should eq 1.0 83 | sleep 0.2 84 | gauge.get.should eq 0.0 85 | end 86 | 87 | it "works when exceptions are raised" do 88 | gauge = Crometheus::Gauge.new(:x, "", nil) 89 | spawn do 90 | begin 91 | gauge.count_concurrent { sleep 0.2; raise CrometheusTestException.new } 92 | rescue ex : CrometheusTestException 93 | end 94 | end 95 | sleep 0.1 96 | gauge.get.should eq 1.0 97 | sleep 0.2 98 | gauge.get.should eq 0.0 99 | end 100 | end 101 | 102 | describe "#samples" do 103 | it "yields appropriate Samples" do 104 | gauge = Crometheus::Gauge.new(:x, "", nil) 105 | gauge.set(11) 106 | get_samples(gauge).should eq [Crometheus::Sample.new(11.0)] 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/histogram_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/histogram" 3 | 4 | describe Crometheus::Histogram do 5 | describe ".new" do 6 | it "allows buckets to be set by default" do 7 | histogram = Crometheus::Histogram.new(:x, "", nil).buckets.keys.should eq([ 8 | 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 9 | Float64::INFINITY, 10 | ]) 11 | end 12 | 13 | it "allows buckets to be set explicitly" do 14 | Crometheus::Histogram.new(:x, "", nil, 15 | buckets: [0.1, 0.25, 0.5, 1.0] 16 | ).buckets.keys.should eq([0.1, 0.25, 0.5, 1.0, Float64::INFINITY]) 17 | end 18 | end 19 | 20 | describe "#linear_buckets" do 21 | it "creates linearly increasing buckets" do 22 | histogram = Crometheus::Histogram.new(:x, "", nil, 23 | buckets: Crometheus::Histogram.linear_buckets(1, 2, 5) 24 | ).buckets.keys.should eq [ 25 | 1.0, 3.0, 5.0, 7.0, 9.0, Float64::INFINITY, 26 | ] 27 | 28 | histogram = Crometheus::Histogram.new(:x, "", nil, 29 | buckets: Crometheus::Histogram.linear_buckets(-20, 10, 4) 30 | ).buckets.keys.should eq [ 31 | -20.0, -10.0, 0.0, 10.0, Float64::INFINITY, 32 | ] 33 | end 34 | end 35 | 36 | describe "#geometric_buckets" do 37 | it "returns a geometrically increasing Array of Float64s" do 38 | histogram = Crometheus::Histogram.new(:x, "", nil, 39 | buckets: Crometheus::Histogram.geometric_buckets(1, 2, 4) 40 | ).buckets.keys.should eq [ 41 | 1.0, 2.0, 4.0, 8.0, Float64::INFINITY, 42 | ] 43 | 44 | histogram = Crometheus::Histogram.new(:x, "", nil, 45 | buckets: Crometheus::Histogram.geometric_buckets(2, 1.5, 5) 46 | ).buckets.keys.should eq [ 47 | 2.0, 3.0, 4.5, 6.75, 10.125, Float64::INFINITY, 48 | ] 49 | end 50 | end 51 | 52 | describe "#observe" do 53 | it "updates @count, @sum, and the appropriate buckets" do 54 | histogram = Crometheus::Histogram.new(:x, "", nil, 55 | buckets: [1.0, 2.0, 7.0, 11.0]) 56 | 57 | histogram.observe(7) 58 | histogram.count.should eq 1.0 59 | histogram.sum.should eq 7.0 60 | histogram.buckets.should eq({ 61 | 1.0 => 0.0, 62 | 2.0 => 0.0, 63 | 7.0 => 1.0, 64 | 11.0 => 1.0, 65 | Float64::INFINITY => 1.0, 66 | }) 67 | histogram.observe(17.5) 68 | histogram.count.should eq 2.0 69 | histogram.sum.should eq 24.5 70 | histogram.buckets.should eq({ 71 | 1.0 => 0.0, 72 | 2.0 => 0.0, 73 | 7.0 => 1.0, 74 | 11.0 => 1.0, 75 | Float64::INFINITY => 2.0, 76 | }) 77 | end 78 | end 79 | 80 | describe "#measure_runtime" do 81 | histogram = Crometheus::Histogram.new(:x, "", nil, 82 | buckets: [0.1, 0.25, 0.5, 1.0]) 83 | it "yields, then observes the runtime of the block" do 84 | histogram.measure_runtime { sleep 0.2 } 85 | histogram.buckets.should eq({ 86 | 0.100 => 0.0, 87 | 0.250 => 1.0, 88 | 0.500 => 1.0, 89 | 1.000 => 1.0, 90 | Float64::INFINITY => 1.0, 91 | }) 92 | histogram.count.should eq 1.0 93 | (0.15..0.25).should contain histogram.sum 94 | 95 | histogram.measure_runtime { sleep 0.3 } 96 | histogram.buckets.should eq({ 97 | 0.100 => 0.0, 98 | 0.250 => 1.0, 99 | 0.500 => 2.0, 100 | 1.000 => 2.0, 101 | Float64::INFINITY => 2.0, 102 | }) 103 | histogram.count.should eq 2.0 104 | (0.45..0.55).should contain histogram.sum 105 | end 106 | 107 | it "works even when the block raises an exception" do 108 | expect_raises (CrometheusTestException) do 109 | histogram.measure_runtime { sleep 0.3; raise CrometheusTestException.new } 110 | end 111 | histogram.buckets.should eq({ 112 | 0.100 => 0.0, 113 | 0.250 => 1.0, 114 | 0.500 => 3.0, 115 | 1.000 => 3.0, 116 | Float64::INFINITY => 3.0, 117 | }) 118 | histogram.count.should eq 3.0 119 | (0.75..0.85).should contain histogram.sum 120 | end 121 | end 122 | 123 | describe "#samples" do 124 | it "yields appropriate Samples" do 125 | histogram1 = Crometheus::Histogram.new(:x, "", nil, 126 | buckets: [0.1, 0.25, 0.5, 1.0]) 127 | histogram1.observe(0.11) 128 | histogram1.observe(0.22) 129 | histogram1.observe(0.33) 130 | histogram1.observe(0.44) 131 | 132 | expected = [ 133 | Crometheus::Sample.new(4.0, suffix: "count"), 134 | Crometheus::Sample.new(1.1, suffix: "sum"), 135 | Crometheus::Sample.new(0.0, labels: {:le => "0.1"}, suffix: "bucket"), 136 | Crometheus::Sample.new(2.0, labels: {:le => "0.25"}, suffix: "bucket"), 137 | Crometheus::Sample.new(4.0, labels: {:le => "0.5"}, suffix: "bucket"), 138 | Crometheus::Sample.new(4.0, labels: {:le => "1.0"}, suffix: "bucket"), 139 | Crometheus::Sample.new(4.0, labels: {:le => "+Inf"}, suffix: "bucket"), 140 | ] 141 | get_samples(histogram1).size.should eq expected.size 142 | get_samples(histogram1).zip(expected).each do |actual, expected| 143 | actual.should eq expected 144 | end 145 | 146 | histogram2 = Crometheus::Histogram.new(:x, "", nil, 147 | buckets: [1.0, 2.0, 7.0, 11.0]) 148 | histogram2.observe(7) 149 | histogram2.observe(17.5) 150 | 151 | expected = [ 152 | Crometheus::Sample.new(2.0, suffix: "count"), 153 | Crometheus::Sample.new(24.5, suffix: "sum"), 154 | Crometheus::Sample.new(0.0, labels: {:le => "1.0"}, suffix: "bucket"), 155 | Crometheus::Sample.new(0.0, labels: {:le => "2.0"}, suffix: "bucket"), 156 | Crometheus::Sample.new(1.0, labels: {:le => "7.0"}, suffix: "bucket"), 157 | Crometheus::Sample.new(1.0, labels: {:le => "11.0"}, suffix: "bucket"), 158 | Crometheus::Sample.new(2.0, labels: {:le => "+Inf"}, suffix: "bucket"), 159 | ] 160 | get_samples(histogram2).size.should eq expected.size 161 | get_samples(histogram2).zip(expected).each do |actual, expected| 162 | actual.should eq expected 163 | end 164 | end 165 | end 166 | 167 | describe ".valid_label?" do 168 | it "disallows \"le\" as a label" do 169 | Crometheus::Histogram.valid_label?(:le).should eq false 170 | end 171 | 172 | it "adheres to standard label restrictions" do 173 | Crometheus::Histogram.valid_label?(:"~").should eq false 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /spec/metric_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/metric" 3 | 4 | class Simple < Crometheus::Metric 5 | def samples(&block : Crometheus::Sample -> Nil) : Nil 6 | yield Crometheus::Sample.new(1.0) 7 | end 8 | 9 | def self.type 10 | Crometheus::Metric::Type::Untyped 11 | end 12 | end 13 | 14 | class LessSimple < Crometheus::Metric 15 | getter value : Float64 16 | 17 | def initialize(@name : Symbol, 18 | @docstring : String, 19 | @value : Float64, 20 | register_with : Crometheus::Registry? = Crometheus.default_registry) 21 | end 22 | 23 | def samples(&block : Crometheus::Sample -> Nil) : Nil 24 | yield Crometheus::Sample.new(2.0) 25 | end 26 | 27 | def self.type 28 | Crometheus::Metric::Type::Untyped 29 | end 30 | end 31 | 32 | describe Crometheus::Metric do 33 | describe ".new" do 34 | it "automatically registers with the default registry" do 35 | simple = Simple.new(:a, "") 36 | Crometheus.default_registry.metrics.should contain simple 37 | end 38 | 39 | it "registers with a registry passed to the constructor" do 40 | registry = Crometheus::Registry.new 41 | simple = Simple.new(:b, "", registry) 42 | registry.metrics.should contain simple 43 | Crometheus.default_registry.metrics.should_not contain simple 44 | end 45 | 46 | it "rejects unacceptable names" do 47 | expect_raises(ArgumentError) { Simple.new(:"123", "") } 48 | expect_raises(ArgumentError) { Simple.new(:"a&b", "") } 49 | end 50 | end 51 | 52 | describe ".valid_labels?" do 53 | it "returns true on most hashes" do 54 | Crometheus::Metric.valid_label?(:foo).should eq true 55 | Crometheus::Metric.valid_label?(:_baz).should eq true 56 | end 57 | it "fails when a key starts with __" do 58 | Crometheus::Metric.valid_label?(:__reserved).should eq(false) 59 | end 60 | it "fails on reserved labels" do 61 | Crometheus::Metric.valid_label?(:job).should eq(false) 62 | Crometheus::Metric.valid_label?(:instance).should eq(false) 63 | end 64 | it "fails when labels don't match [a-zA-Z_][a-zA-Z0-9_]*" do 65 | Crometheus::Metric.valid_label?(:"foo*bar").should eq(false) 66 | end 67 | end 68 | 69 | describe ".[]" do 70 | it "creates a LabeledMetric" do 71 | Simple[:foo, :bar].new(:x, "", nil).should be_a( 72 | Crometheus::Metric::LabeledMetric(NamedTuple(foo: String, bar: String), Simple) 73 | ) 74 | end 75 | end 76 | 77 | describe Crometheus::Metric::LabeledMetric do 78 | describe "#[]" do 79 | it "creates uniquely identified metrics" do 80 | simple = Simple[:foo, :bar].new(:x, "", nil) 81 | metric1 = simple[foo: "a", bar: "b"] 82 | metric1.class.should eq Simple 83 | metric1.should eq simple[foo: "a", bar: "b"] 84 | metric1.should_not eq simple[foo: "a", bar: "c"] 85 | end 86 | end 87 | 88 | describe "#get_labels" do 89 | it "returns an array of every labelset in use" do 90 | simple = Simple[:foo, :bar].new(:x, "", nil) 91 | simple.get_labels.should eq [] of NamedTuple(foo: String, bar: String) 92 | simple[foo: "a", bar: "b"] 93 | simple[foo: "x", bar: "y"] 94 | simple.get_labels.should eq [{foo: "a", bar: "b"}, {foo: "x", bar: "y"}] 95 | end 96 | end 97 | 98 | describe "#samples" do 99 | it "yields a sample for every labelset" do 100 | simple = Simple[:foo, :bar].new(:x, "", nil) 101 | get_samples(simple).should eq [] of Crometheus::Sample 102 | simple[foo: "a", bar: "b"] 103 | simple[foo: "x", bar: "y"] 104 | get_samples(simple).should eq [ 105 | Crometheus::Sample.new(1.0, labels: {:foo => "a", :bar => "b"}), 106 | Crometheus::Sample.new(1.0, labels: {:foo => "x", :bar => "y"}), 107 | ] 108 | end 109 | end 110 | 111 | describe "#remove" do 112 | it "deletes the metric with the given labelset" do 113 | simple = Simple[:foo, :bar].new(:x, "", nil) 114 | simple[foo: "a", bar: "b"] 115 | simple[foo: "x", bar: "y"] 116 | simple.remove(foo: "a", bar: "b") 117 | simple.get_labels.should eq [{foo: "x", bar: "y"}] 118 | get_samples(simple).should eq [ 119 | Crometheus::Sample.new(1.0, labels: {:foo => "x", :bar => "y"}), 120 | ] 121 | end 122 | end 123 | 124 | describe "#clear" do 125 | it "deletes all metrics" do 126 | simple = Simple[:foo, :bar].new(:x, "", nil) 127 | simple[foo: "a", bar: "b"] 128 | simple[foo: "x", bar: "y"] 129 | simple.clear 130 | simple.get_labels.should eq [] of NamedTuple(foo: String, bar: String) 131 | get_samples(simple).should eq [] of Crometheus::Sample 132 | end 133 | end 134 | 135 | it "passes unknown kwargs to Metric objects" do 136 | less_simple1 = LessSimple[:foo].new(:x, "", nil, value: 12.0) 137 | less_simple1[foo: "x"].value.should eq 12.0 138 | 139 | less_simple2 = LessSimple[:foo].new(:x, "", value: 13.0) 140 | less_simple2[foo: "x"].value.should eq 13.0 141 | end 142 | end 143 | end 144 | 145 | Crometheus.alias MyType = Simple[:one, :two] 146 | describe "Crometheus.alias" do 147 | it "creates an alias" do 148 | MyType.new(:x, "", nil).should be_a( 149 | Crometheus::Metric::LabeledMetric(NamedTuple(one: String, two: String), Simple) 150 | ) 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/middleware/http_collector_spec.cr: -------------------------------------------------------------------------------- 1 | require "http/server/context" 2 | require "../spec_helper" 3 | require "../../src/crometheus/middleware/http_collector" 4 | 5 | Crometheus.default_registry(false) 6 | 7 | def request(collector, method, resource, body : String? = nil) 8 | ctx = HTTP::Server::Context.new( 9 | HTTP::Request.new(method, resource, body: body), 10 | HTTP::Server::Response.new(IO::MultiWriter.new([] of IO)) 11 | ) 12 | collector.call(ctx) 13 | return ctx 14 | end 15 | 16 | describe Crometheus::Middleware::HttpCollector do 17 | it "tracks requests" do 18 | registry = Crometheus::Registry.new(false) 19 | collector = Crometheus::Middleware::HttpCollector.new(registry) 20 | request(collector, "GET", "/foo/bar/1") 21 | request(collector, "GET", "/foo/bar/2") 22 | request(collector, "POST", "/foo/bar/") 23 | request(collector, "GET", "/foo/quux") 24 | 25 | metric = registry.metrics.find { |mm| 26 | mm.name == :http_requests_total 27 | }.as(Crometheus::Middleware::HttpCollector::RequestCounter) 28 | metric[code: "404", method: "GET", path: "/foo/bar/:id"].get.should eq 2 29 | metric[code: "404", method: "POST", path: "/foo/bar/"].get.should eq 1 30 | metric[code: "404", method: "GET", path: "/foo/quux"].get.should eq 1 31 | metric.get_labels.size.should eq 3 32 | end 33 | 34 | it "tracks durations" do 35 | registry = Crometheus::Registry.new(false) 36 | collector = Crometheus::Middleware::HttpCollector.new(registry) 37 | x = 0.001 38 | collector.next = ->(ctx : HTTP::Server::Context) { sleep x; x += 0.02 } 39 | request(collector, "GET", "/one") 40 | request(collector, "GET", "/one") 41 | request(collector, "OPTIONS", "/two") 42 | request(collector, "OPTIONS", "/two") 43 | request(collector, "DELETE", "/123/456/three") 44 | request(collector, "GET", "/one") 45 | 46 | metric = registry.metrics.find { |mm| 47 | mm.name == :http_request_duration_seconds 48 | }.as(Crometheus::Middleware::HttpCollector::DurationHistogram) 49 | metric[method: "GET", path: "/one"].buckets.values.should eq [ 50 | 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 51 | ] 52 | metric[method: "OPTIONS", path: "/two"].buckets.values.should eq [ 53 | 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 54 | ] 55 | metric[method: "DELETE", path: "/:id/:id/three"].buckets.values.should eq [ 56 | 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 57 | ] 58 | metric.get_labels.size.should eq 3 59 | end 60 | 61 | it "tracks exceptions" do 62 | registry = Crometheus::Registry.new(false) 63 | collector = Crometheus::Middleware::HttpCollector.new(registry) 64 | collector.next = ->(ctx : HTTP::Server::Context) do 65 | str = ctx.request.body.as(IO).gets_to_end 66 | str[2] 67 | str.to_i 68 | end 69 | request(collector, "POST", "/", "123") 70 | request(collector, "POST", "/", "6") rescue IndexError 71 | request(collector, "POST", "/", "2") rescue IndexError 72 | request(collector, "POST", "/", "purple") rescue ArgumentError 73 | request(collector, "PUT", "/", "green") rescue ArgumentError 74 | request(collector, "PUT", "/", "gray") rescue ArgumentError 75 | 76 | metric = registry.metrics.find { |mm| 77 | mm.name == :http_request_exceptions_total 78 | }.as(Crometheus::Middleware::HttpCollector::ExceptionCounter) 79 | metric[method: "POST", path: "/", exception: "IndexError"].get.should eq 2 80 | metric[method: "POST", path: "/", exception: "ArgumentError"].get.should eq 1 81 | metric[method: "PUT", path: "/", exception: "ArgumentError"].get.should eq 2 82 | metric.get_labels.size.should eq 3 83 | end 84 | 85 | it "uses the default registry" do 86 | collector = Crometheus::Middleware::HttpCollector.new 87 | metrics = Crometheus.default_registry.metrics 88 | 89 | metrics.find { |mm| 90 | mm.name == :http_requests_total 91 | }.should be_a(Crometheus::Middleware::HttpCollector::RequestCounter) 92 | metrics.find { |mm| 93 | mm.name == :http_request_duration_seconds 94 | }.should be_a(Crometheus::Middleware::HttpCollector::DurationHistogram) 95 | metrics.find { |mm| 96 | mm.name == :http_request_exceptions_total 97 | }.should be_a(Crometheus::Middleware::HttpCollector::ExceptionCounter) 98 | end 99 | 100 | it "accepts a custom path cleaner" do 101 | registry = Crometheus::Registry.new(false) 102 | collector = Crometheus::Middleware::HttpCollector.new( 103 | registry, 104 | ->(path : String) { "~#{path}~" } 105 | ) 106 | request(collector, "GET", "blah") 107 | 108 | metric = registry.metrics.find { |mm| 109 | mm.name == :http_requests_total 110 | }.as(Crometheus::Middleware::HttpCollector::RequestCounter) 111 | metric[code: "404", method: "GET", path: "~blah~"].get.should eq 1 112 | end 113 | 114 | it "accepts a nil path cleaner" do 115 | registry = Crometheus::Registry.new(false) 116 | collector = Crometheus::Middleware::HttpCollector.new(registry, nil) 117 | request(collector, "GET", "/blah/12") 118 | 119 | metric = registry.metrics.find { |mm| 120 | mm.name == :http_requests_total 121 | }.as(Crometheus::Middleware::HttpCollector::RequestCounter) 122 | metric[code: "404", method: "GET", path: "/blah/12"].get.should eq 1 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/proc/26231/fd/0: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darwinnn/crometheus/c71a13174d02e3767b62faa1771132d4245e1ce5/spec/proc/26231/fd/0 -------------------------------------------------------------------------------- /spec/proc/26231/fd/1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darwinnn/crometheus/c71a13174d02e3767b62faa1771132d4245e1ce5/spec/proc/26231/fd/1 -------------------------------------------------------------------------------- /spec/proc/26231/fd/2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darwinnn/crometheus/c71a13174d02e3767b62faa1771132d4245e1ce5/spec/proc/26231/fd/2 -------------------------------------------------------------------------------- /spec/proc/26231/fd/3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darwinnn/crometheus/c71a13174d02e3767b62faa1771132d4245e1ce5/spec/proc/26231/fd/3 -------------------------------------------------------------------------------- /spec/proc/26231/fd/4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darwinnn/crometheus/c71a13174d02e3767b62faa1771132d4245e1ce5/spec/proc/26231/fd/4 -------------------------------------------------------------------------------- /spec/proc/26231/limits: -------------------------------------------------------------------------------- 1 | Limit Soft Limit Hard Limit Units 2 | Max cpu time unlimited unlimited seconds 3 | Max file size unlimited unlimited bytes 4 | Max data size unlimited unlimited bytes 5 | Max stack size 8388608 unlimited bytes 6 | Max core file size 0 unlimited bytes 7 | Max resident set unlimited unlimited bytes 8 | Max processes 62898 62898 processes 9 | Max open files 2048 4096 files 10 | Max locked memory 65536 65536 bytes 11 | Max address space unlimited unlimited bytes 12 | Max file locks unlimited unlimited locks 13 | Max pending signals 62898 62898 signals 14 | Max msgqueue size 819200 819200 bytes 15 | Max nice priority 0 0 16 | -------------------------------------------------------------------------------- /spec/proc/26231/stat: -------------------------------------------------------------------------------- 1 | 26231 (vim) R 5392 7446 5392 34835 7446 4218880 32533 309516 26 82 1677 44 158 99 20 0 1 0 82375 56274944 1981 18446744073709551615 4194304 6294284 140736914091744 140736914087944 139965136429984 0 0 12288 1870679807 0 0 0 17 0 0 0 31 0 0 8391624 8481048 16420864 140736914093252 140736914093279 140736914093279 140736914096107 0 2 | -------------------------------------------------------------------------------- /spec/proc/stat: -------------------------------------------------------------------------------- 1 | cpu 301854 612 111922 8979004 3552 2 3944 0 0 0 2 | cpu0 44490 19 21045 1087069 220 1 3410 0 0 0 3 | cpu1 47869 23 16474 1110787 591 0 46 0 0 0 4 | cpu2 46504 36 15916 1112321 441 0 326 0 0 0 5 | cpu3 47054 102 15683 1113230 533 0 60 0 0 0 6 | cpu4 28413 25 10776 1140321 217 0 8 0 0 0 7 | cpu5 29271 101 11586 1136270 672 0 30 0 0 0 8 | cpu6 29152 36 10276 1139721 319 0 29 0 0 0 9 | cpu7 29098 268 10164 1139282 555 0 31 0 0 0 10 | intr 8885917 17 0 0 0 0 0 0 0 1 79281 0 0 0 0 0 0 0 231237 0 0 0 0 250586 103 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 223424 190745 13 906 1283803 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0intr 8885917 17 0 0 0 0 0 0 0 1 79281 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 11 | ctxt 38014093 12 | btime 1418183276 13 | processes 26442 14 | procs_running 2 15 | procs_blocked 0 16 | softirq 5057579 250191 1481983 1647 211099 186066 0 1783454 622196 12499 508444 17 | -------------------------------------------------------------------------------- /spec/registry_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/registry" 3 | require "../src/crometheus/gauge" 4 | require "../src/crometheus/counter" 5 | require "../src/crometheus/histogram" 6 | require "../src/crometheus/summary" 7 | 8 | describe Crometheus::Registry do 9 | describe "#initialize" do 10 | it "adds standard exports by default" do 11 | {% if flag?(:linux) %} 12 | Crometheus::Registry.new.metrics.map { |mm| {mm.class, mm.name} }.should eq([ 13 | {Crometheus::StandardExports::ProcFSExports, :process}, 14 | ]) 15 | {% else %} 16 | Crometheus::Registry.new.metrics.map { |mm| {mm.class, mm.name} }.should eq([ 17 | {Crometheus::StandardExports, :process}, 18 | ]) 19 | {% end %} 20 | end 21 | 22 | it "can be created without standard exports" do 23 | Crometheus::Registry.new(false).metrics.should eq [] of Crometheus::Metric 24 | end 25 | end 26 | 27 | describe "#register" do 28 | it "ingests metrics passed to it" do 29 | registry = Crometheus::Registry.new(false) 30 | gauge1 = Crometheus::Gauge.new(:a, "", nil) 31 | gauge2 = Crometheus::Gauge.new(:b, "", nil) 32 | registry.register gauge1 33 | registry.register gauge2 34 | registry.metrics.should eq [gauge1, gauge2] 35 | end 36 | 37 | it "enforces unique metric names" do 38 | registry = Crometheus::Registry.new(false) 39 | gauge1 = Crometheus::Gauge.new(:a, "", nil) 40 | gauge2 = Crometheus::Gauge.new(:a, "", nil) 41 | registry.register gauge1 42 | expect_raises(ArgumentError) { registry.register gauge2 } 43 | end 44 | end 45 | 46 | describe "#unregister" do 47 | it "deletes metrics from the registry" do 48 | registry = Crometheus::Registry.new(false) 49 | gauge1 = Crometheus::Gauge.new(:a, "", nil) 50 | gauge2 = Crometheus::Gauge.new(:b, "", nil) 51 | registry.register gauge1 52 | registry.register gauge2 53 | registry.unregister gauge1 54 | registry.metrics.should eq [gauge2] 55 | end 56 | end 57 | 58 | describe "#start_server and #stop_server" do 59 | registry = Crometheus::Registry.new(false) 60 | registry.namespace = "spec" 61 | 62 | gauge1 = Crometheus::Gauge[:test].new(:gauge1, "docstring1", registry) 63 | gauge2 = Crometheus::Gauge.new(:gauge2, "docstring2", registry) 64 | 65 | counter = Crometheus::Counter[:test, 66 | :label1, :label2, :label3, :label4, :label5, :label6, :label7, 67 | :label8, :label9, :label10, 68 | ].new(:counter1, "docstring3", registry) 69 | counter[test: "many labels", label1: "one", label2: "two", 70 | label3: "three", label4: "four", label5: "five", label6: "six", 71 | label7: "seven", label8: "eight", label9: "nine", label10: "ten", 72 | ].inc(1.2345) 73 | 74 | gauge1[test: "infinity"].set(Float64::INFINITY) 75 | gauge1[test: "-infinity"].set(-Float64::INFINITY) 76 | gauge1[test: "nan"].set(-Float64::NAN) 77 | gauge1[test: "large"].set(9.876e54) 78 | gauge1[test: "unicode (ノ◕ヮ◕)ノ*:・゚✧"].set(42) 79 | 80 | histogram = Crometheus::Histogram.new(:histogram1, "docstring4", registry, buckets: [1.0, 2.0, 3.0]) 81 | histogram.observe(1.5) 82 | 83 | summary = Crometheus::Summary.new(:summary1, "docstring5", registry) 84 | summary.observe(100.0) 85 | 86 | registry.start_server.should eq true 87 | sleep 0.5 88 | 89 | response = HTTP::Client.get "http://localhost:5000/metrics" 90 | response.status_code.should eq 200 91 | expected_response = %<\ 92 | # HELP spec_counter1 docstring3 93 | # TYPE spec_counter1 counter 94 | spec_counter1{test="many labels", label1="one", label2="two", label3="three", \ 95 | label4="four", label5="five", label6="six", label7="seven", label8="eight", \ 96 | label9="nine", label10="ten"} 1.2345 97 | # HELP spec_gauge1 docstring1 98 | # TYPE spec_gauge1 gauge 99 | spec_gauge1{test="infinity"} +Inf 100 | spec_gauge1{test="-infinity"} -Inf 101 | spec_gauge1{test="nan"} NaN 102 | spec_gauge1{test="large"} 9.876e+54 103 | spec_gauge1{test="unicode (ノ◕ヮ◕)ノ*:・゚✧"} 42.0 104 | # HELP spec_gauge2 docstring2 105 | # TYPE spec_gauge2 gauge 106 | spec_gauge2 0.0 107 | # HELP spec_histogram1 docstring4 108 | # TYPE spec_histogram1 histogram 109 | spec_histogram1_count 1.0 110 | spec_histogram1_sum 1.5 111 | spec_histogram1_bucket{le="1.0"} 0.0 112 | spec_histogram1_bucket{le="2.0"} 1.0 113 | spec_histogram1_bucket{le="3.0"} 1.0 114 | spec_histogram1_bucket{le="+Inf"} 1.0 115 | # HELP spec_summary1 docstring5 116 | # TYPE spec_summary1 summary 117 | spec_summary1_count 1.0 118 | spec_summary1_sum 100.0 119 | > 120 | it "serves metrics in Prometheus text exposition format v0.0.4" do 121 | response.body.each_line.zip(expected_response.each_line).each do |a, b| 122 | a.should eq b 123 | end 124 | response.body.should eq expected_response 125 | end 126 | 127 | it "stops serving" do 128 | registry.stop_server.should eq true 129 | expect_raises(IO::Error) do 130 | HTTP::Client.get "http://localhost:5000/metrics" 131 | end 132 | end 133 | 134 | it "allows host/port/path configuration" do 135 | registry.host = "localhost" 136 | registry.port = 19009 137 | registry.path = "/xyz" 138 | registry.start_server.should eq true 139 | sleep 0.5 140 | 141 | response = HTTP::Client.get "http://localhost:19009/" 142 | response.status_code.should eq 404 143 | response = HTTP::Client.get "http://localhost:19009/xyz" 144 | response.status_code.should eq 200 145 | response.body.lines.should eq expected_response.lines 146 | 147 | registry.stop_server 148 | registry.path = /a.?b/ 149 | registry.start_server.should eq true 150 | sleep 0.5 151 | 152 | response = HTTP::Client.get "http://localhost:19009/cba" 153 | response.status_code.should eq 404 154 | response = HTTP::Client.get "http://localhost:19009/123abcdef" 155 | response.status_code.should eq 200 156 | response.body.lines.should eq expected_response.lines 157 | response = HTTP::Client.get "http://localhost:19009/x/a/b/c" 158 | response.status_code.should eq 200 159 | response.body.lines.should eq expected_response.lines 160 | 161 | registry.stop_server.should eq true 162 | end 163 | 164 | it "prefixes metrics with namespace" do 165 | registry2 = Crometheus::Registry.new(false) 166 | Crometheus::Gauge.new(:my_gauge, "docstring", registry2).set 15.0 167 | registry2.namespace = "" 168 | registry2.start_server.should eq true 169 | sleep 0.2 170 | HTTP::Client.get("http://localhost:5000/metrics").body.should eq %<\ 171 | # HELP my_gauge docstring 172 | # TYPE my_gauge gauge 173 | my_gauge 15.0 174 | > 175 | registry2.namespace = "ns" 176 | HTTP::Client.get("http://localhost:5000/metrics").body.should eq %<\ 177 | # HELP ns_my_gauge docstring 178 | # TYPE ns_my_gauge gauge 179 | ns_my_gauge 15.0 180 | > 181 | registry2.stop_server 182 | end 183 | end 184 | 185 | describe "#get_handler" do 186 | it "returns an HTTP handler" do 187 | Crometheus::Registry.new(false).get_handler.should be_a HTTP::Handler 188 | end 189 | end 190 | 191 | describe "#namespace=" do 192 | it "rejects improper names" do 193 | registry = Crometheus::Registry.new(false) 194 | expect_raises(ArgumentError) { registry.namespace = "*" } 195 | expect_raises(ArgumentError) { registry.namespace = "a$b" } 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | 3 | class CrometheusTestException < Exception 4 | end 5 | 6 | def get_samples(metric : Crometheus::Metric) 7 | samples = Array(Crometheus::Sample).new 8 | metric.samples { |ss| samples << ss } 9 | return samples 10 | end 11 | -------------------------------------------------------------------------------- /spec/standard_exports_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/standard_exports" 3 | 4 | # This is really just a test that ProcFSExports reads procfs properly. 5 | # The samples from StandardExports come from Crystal itself and can't 6 | # really be verified from the outside. 7 | 8 | # This will fail on non-procfs environments, e.g. MacOS. 9 | # In actual usage, make_standard_exports *should* fall back to 10 | # generating StandardExports instead of ProcFSExports, but this is not 11 | # tested. 12 | # TODO fix this. 13 | 14 | describe Crometheus do 15 | describe ".make_standard_exports" do 16 | it "returns a ProcFSExports object" do 17 | {% if flag?(:linux) %} 18 | Crometheus.make_standard_exports(:x, "", nil).should( 19 | be_a Crometheus::StandardExports::ProcFSExports) 20 | {% else %} 21 | Crometheus.make_standard_exports(:x, "", nil).should( 22 | be_a Crometheus::StandardExports) 23 | {% end %} 24 | end 25 | end 26 | end 27 | 28 | describe Crometheus::StandardExports::ProcFSExports do 29 | it "should return appropriate samples from procfs" do 30 | exports = Crometheus::StandardExports::ProcFSExports.new(:x, "", 31 | nil, 26231, "./spec/proc") 32 | samples = get_samples(exports) 33 | 34 | samples.should contain Crometheus::Sample.new(56274944.0, suffix: "virtual_memory_bytes") 35 | samples.should contain Crometheus::Sample.new(8114176.0, suffix: "resident_memory_bytes") 36 | samples.should contain Crometheus::Sample.new(1418184099.75, suffix: "start_time_seconds") 37 | samples.should contain Crometheus::Sample.new(2048.0, suffix: "max_fds") 38 | samples.should contain Crometheus::Sample.new(5.0, suffix: "open_fds") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/summary_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/crometheus/summary" 3 | 4 | describe Crometheus::Summary do 5 | describe "#observe" do 6 | it "changes the count and sum" do 7 | summary = Crometheus::Summary.new(:x, "", nil) 8 | summary.count.should eq 0.0 9 | summary.sum.should eq 0.0 10 | summary.observe 10 11 | summary.count.should eq 1.0 12 | summary.sum.should eq 10.0 13 | summary.observe 100.0 14 | summary.count.should eq 2.0 15 | summary.sum.should eq 110.0 16 | summary.observe -60.0 17 | summary.count.should eq 3.0 18 | summary.sum.should eq 50.0 19 | end 20 | end 21 | 22 | describe "#reset" do 23 | it "sets count and sum to zero" do 24 | summary = Crometheus::Summary.new(:x, "", nil) 25 | summary.observe 100 26 | summary.reset 27 | summary.count.should eq 0.0 28 | summary.sum.should eq 0.0 29 | end 30 | end 31 | 32 | describe "#measure_runtime" do 33 | it "yields and increases sum by the runtime of the block" do 34 | summary = Crometheus::Summary.new(:x, "", nil) 35 | summary.measure_runtime { sleep 0.1 } 36 | summary.count.should eq 1.0 37 | (0.05..0.15).should contain summary.sum 38 | end 39 | 40 | it "works even when the block raises an exception" do 41 | summary = Crometheus::Summary.new(:x, "", nil) 42 | expect_raises (CrometheusTestException) do 43 | summary.measure_runtime { sleep 0.3; raise CrometheusTestException.new } 44 | end 45 | summary.measure_runtime { sleep 0.1 } 46 | summary.count.should eq 2.0 47 | (0.35..0.45).should contain summary.sum 48 | end 49 | end 50 | 51 | describe "#samples" do 52 | it "yields appropriate Samples" do 53 | summary = Crometheus::Summary.new(:x, "", nil) 54 | summary.observe(0.1) 55 | summary.observe(0.3) 56 | get_samples(summary).should eq [ 57 | Crometheus::Sample.new(suffix: "count", value: 2.0), 58 | Crometheus::Sample.new(suffix: "sum", value: 0.4), 59 | ] 60 | 61 | summary2 = Crometheus::Summary.new(:x, "", nil) 62 | summary2.observe(-20) 63 | get_samples(summary2).should eq [ 64 | Crometheus::Sample.new(suffix: "count", value: 1.0), 65 | Crometheus::Sample.new(suffix: "sum", value: -20.0), 66 | ] 67 | end 68 | end 69 | 70 | describe ".valid_labels?" do 71 | it "disallows \"quantile\" as a label" do 72 | Crometheus::Summary.valid_label?(:quantile).should eq false 73 | end 74 | 75 | it "adheres to standard label restrictions" do 76 | Crometheus::Summary.valid_label?(:__reserved).should eq false 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/crometheus.cr: -------------------------------------------------------------------------------- 1 | require "./crometheus/*" 2 | require "./crometheus/middleware/*" 3 | 4 | # Crometheus is a [Prometheus](https://prometheus.io/) client library 5 | # for instrumenting programs written in the Crystal programming 6 | # language. The official site is located at [https://gitlab.com/ezrast/crometheus](https://gitlab.com/ezrast/crometheus). 7 | module Crometheus 8 | end 9 | -------------------------------------------------------------------------------- /src/crometheus/counter.cr: -------------------------------------------------------------------------------- 1 | require "./metric" 2 | require "./registry" 3 | 4 | module Crometheus 5 | # Counter is a `Metric` type that stores a single value internally. 6 | # This value can be reset to zero, but otherwises increases 7 | # monotonically, and only when `#inc` is called. 8 | # ``` 9 | # require "crometheus/counter" 10 | # 11 | # flowers_planted = Crometheus::Counter.new( 12 | # :flowers_planted, "Number of flowers planted") 13 | # flowers_planted.inc 10 14 | # flowers_planted.inc 15 | # flowers_planted.inc 16 | # flowers_planted.get # => 12.0 17 | # ``` 18 | class Counter < Metric 19 | @value = 0.0 20 | 21 | # Fetches the current counter value. 22 | def get 23 | @value 24 | end 25 | 26 | # Increments the counter value by the given number, or `1.0`. 27 | def inc(x : Int8 | Int16 | Int32 | Int64 | Int128 | Float32 | Float64 = 1.0) 28 | raise ArgumentError.new "Counter increments must be non-negative" if x < 0 29 | @value += x 30 | end 31 | 32 | # Sets the counter value to `0.0`. 33 | def reset 34 | @value = 0.0 35 | end 36 | 37 | # Yields to the block, calling #inc if an exception is raised. 38 | # The exception is always re-raised. 39 | # ``` 40 | # require "crometheus/counter" 41 | # include Crometheus 42 | # 43 | # def risky_code 44 | # x = 1 / [1, 0].sample 45 | # end 46 | # 47 | # counter = Counter.new :example, "" 48 | # 100.times do 49 | # begin 50 | # counter.count_exceptions { risky_code } 51 | # rescue DivisionByZero 52 | # end 53 | # end 54 | # puts counter.get # approximately 50 55 | # ``` 56 | def count_exceptions 57 | yield 58 | rescue ex 59 | inc 60 | raise ex 61 | end 62 | 63 | # Yields a single Sample bearing the counter value. See 64 | # `Metric#samples`. 65 | def samples(&block : Sample -> Nil) : Nil 66 | yield Sample.new(@value) 67 | end 68 | 69 | # Returns `Type::Counter`. See `Metric.type`. 70 | def self.type 71 | Type::Counter 72 | end 73 | 74 | # Yields to the block, incrementing the given counter when an 75 | # exception matching the given type is raised. At a future date, 76 | # this macro will be deprecated and its functionality folded into 77 | # `#count_exceptions`. 78 | # Pending https://github.com/crystal-lang/crystal/issues/2060. 79 | macro count_exceptions_of_type(counter, ex_type, &block) 80 | begin 81 | {{yield}} 82 | rescue ex : {{ex_type}} 83 | {{counter}}.inc 84 | raise ex 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /src/crometheus/exceptions.cr: -------------------------------------------------------------------------------- 1 | module Crometheus 2 | # An exception raised when a metric fails to generate a usable value. 3 | class Exceptions::InstrumentationError < Exception 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/crometheus/gauge.cr: -------------------------------------------------------------------------------- 1 | require "./metric" 2 | 3 | module Crometheus 4 | # Gauge is a `Metric` type that stores a single value internally. 5 | # This value can be modified freely via instance methods. 6 | # ``` 7 | # require "crometheus/gauge" 8 | # 9 | # body_temperature = Crometheus::Gauge.new( 10 | # :body_temperature, "Human body temperature") 11 | # body_temperature.set 98.6 12 | # 13 | # # Running a fever... 14 | # body_temperature.inc 1.8 15 | # # Partial recovery 16 | # body_temperature.dec 0.6 17 | # 18 | # body_temperature.get # => 99.8 19 | # ``` 20 | class Gauge < Metric 21 | @value : Float64 = 0.0 22 | 23 | # Fetches the gauge value. 24 | def get 25 | @value 26 | end 27 | 28 | # Sets the gauge value to the given number. 29 | def set(x : Int | Float) 30 | @value = x.to_f64 31 | end 32 | 33 | # Increments the gauge value by the given number, or 1.0. 34 | def inc(x : Int | Float = 1.0) 35 | set @value + x.to_f64 36 | end 37 | 38 | # Decrements the gauge value by the given number, or 1.0. 39 | def dec(x : Int | Float = 1.0) 40 | set @value - x.to_f64 41 | end 42 | 43 | # Sets the gauge value to the current UNIX timestamp. 44 | def set_to_current_time 45 | set Time.utc.to_unix_f 46 | end 47 | 48 | # Yields, then sets the gauge value to the block's runtime. 49 | def measure_runtime 50 | t0 = Time.utc 51 | begin 52 | yield 53 | ensure 54 | t1 = Time.utc 55 | set((t1 - t0).to_f) 56 | end 57 | end 58 | 59 | # Increments the gauge value, yields, then decrements the gauge 60 | # value. Wrap your event handlers with this to find out how many 61 | # events are being processed at a time. 62 | def count_concurrent 63 | inc 64 | yield 65 | ensure 66 | dec 67 | end 68 | 69 | # Returns `Type::Gauge`. See `Metric.type`. 70 | def self.type 71 | Type::Gauge 72 | end 73 | 74 | # Yields a single Sample bearing the gauge value. 75 | # See `Metric#samples`. 76 | def samples(&block : Sample -> Nil) : Nil 77 | yield Sample.new(@value) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /src/crometheus/histogram.cr: -------------------------------------------------------------------------------- 1 | require "./metric" 2 | require "./stringify" 3 | 4 | module Crometheus 5 | # `Histogram` is a `Metric` type that tracks how many observations 6 | # fit into a series of ranges, or buckets. Each bucket is defined by 7 | # its upper bound. Whenever `#observe` is called, the value of each 8 | # bucket with a bound equal to or greater than the observed value is 9 | # incremented. A running sum of all observed values is also tracked. 10 | # ``` 11 | # require "crometheus/histogram" 12 | # 13 | # buckets = Crometheus::Histogram.linear_buckets(60, 30, 10) 14 | # # => [60.0, 90.0, 120.0, ... , 300.0, 330.0, Infinity] 15 | # 16 | # hold_times = Crometheus::Histogram.new( 17 | # :hold_times, "Time spent on hold", buckets: buckets) 18 | # hold_times.observe 35.4 19 | # hold_times.observe 214.1 20 | # hold_times.observe 179.0 21 | # hold_times.observe 118.0 22 | # hold_times.observe 384.4 23 | # 24 | # under_2_min = hold_times.buckets[120.0] / hold_times.count # => 0.4 25 | # ``` 26 | class Histogram < Metric 27 | @@default_buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 28 | 2.5, 5, 10] 29 | 30 | # A mapping of upper bounds to bucket values. 31 | getter buckets = {} of Float64 => Float64 32 | 33 | # A running sum of all observed values. 34 | getter sum = 0.0 35 | 36 | # In addition to the standard arguments for `Metric.new`, takes an 37 | # array that defines the range of each bucket. 38 | # The `.linear_buckets` and `.geometric_buckets` convenience methods 39 | # may be used to generate an appropriate array. 40 | # A bucket for Infinity will be added if it is not already part of 41 | # the array. 42 | # If left unspecified, buckets will default to 43 | # `[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +Inf]`. 44 | def initialize(name : Symbol, 45 | docstring : String, 46 | register_with : Crometheus::Registry? = Crometheus.default_registry, 47 | buckets : Array(Float | Int) = @@default_buckets) 48 | super(name, docstring, register_with) 49 | buckets.each do |le| 50 | @buckets[le.to_f64] = 0.0 51 | end 52 | @buckets[Float64::INFINITY] = 0.0 53 | end 54 | 55 | # Increments the value of all buckets whose range includes `value`. 56 | # Also increases `sum` by `value`. 57 | def observe(value) 58 | @buckets.each_key do |le| 59 | @buckets[le] += 1.0 if value <= le 60 | end 61 | @sum += value 62 | end 63 | 64 | # Returns the value of the Infinity bucket. This is equal to the 65 | # total number of observations performed by this histogram. 66 | def count : Float64 67 | @buckets[Float64::INFINITY] 68 | end 69 | 70 | # Resets all bucket values, as well as `sum`, to `0.0`. 71 | def reset 72 | @buckets.each_key do |le| 73 | @buckets[le] = 0.0 74 | end 75 | @sum = 0.0 76 | end 77 | 78 | # Yields to the block, then passes the block's runtime to 79 | # `#observe`. 80 | def measure_runtime(&block) 81 | t0 = Time.utc 82 | begin 83 | return yield 84 | ensure 85 | t1 = Time.utc 86 | observe((t1 - t0).to_f) 87 | end 88 | end 89 | 90 | # Yields one `Sample` for each bucket, in addition to one for 91 | # `count` (equal to the infinity bucket) and one for `sum`. See 92 | # `Metric#samples`. 93 | def samples(&block : Sample -> Nil) : Nil 94 | yield Sample.new(@buckets[Float64::INFINITY], suffix: "count") 95 | yield Sample.new(@sum, suffix: "sum") 96 | @buckets.each do |le, value| 97 | yield Sample.new(value, labels: {:le => Crometheus.stringify(le).to_s}, suffix: "bucket") 98 | end 99 | end 100 | 101 | # Returns `Type::Histogram`. See `Metric.type`. 102 | def self.type 103 | Type::Histogram 104 | end 105 | 106 | # Returns an array of linearly-increasing bucket upper bounds 107 | # suitable for passing into the constructor of `Histogram`. 108 | # `bucket_count` excludes the Infinity bucket. 109 | # ``` 110 | # require "crometheus/histogram" 111 | # include Crometheus 112 | # 113 | # hist = Histogram.new(:evens, "even teens", 114 | # buckets: Histogram.linear_buckets(12, 2, 4)) 115 | # 116 | # hist.buckets # => {12.0 => 0.0, 14.0 => 0.0, 16.0 => 0.0, 117 | # # 18.0 => 0.0, Infinity => 0.0} 118 | # ``` 119 | def self.linear_buckets(start, step, bucket_count) : Array(Float64) 120 | ary = [] of Float64 121 | start = start.to_f64 122 | step = step.to_f64 123 | bucket_count.times do |ii| 124 | ary << start + step * ii 125 | end 126 | return ary << Float64::INFINITY 127 | end 128 | 129 | # Returns an array of geometrically-increasing bucket upper bounds 130 | # suitable for passing into the constructor of `Histogram`. 131 | # `bucket_count` excludes the Infinity bucket. 132 | # ``` 133 | # require "crometheus/histogram" 134 | # include Crometheus 135 | # 136 | # hist = Histogram.new(:powers, "powers of two", 137 | # buckets: Histogram.geometric_buckets(1, 2, 4)) 138 | # 139 | # hist.buckets # => {1.0 => 0.0, 2.0 => 0.0, 4.0 => 0.0, 140 | # # 8.0 => 0.0, Infinity => 0.0} 141 | # ``` 142 | def self.geometric_buckets(start, factor, bucket_count) : Array(Float64) 143 | ary = [] of Float64 144 | start = start.to_f64 145 | factor = factor.to_f64 146 | bucket_count.times do |ii| 147 | ary << start * factor ** ii 148 | end 149 | return ary << Float64::INFINITY 150 | end 151 | 152 | # In addition to the standard `Metric.valid_label?` behavior, 153 | # returns `false` if a label is `:le`. Histograms reserve this label 154 | # for bucket upper bounds. 155 | def self.valid_label?(label : Symbol) 156 | return false if :le == label 157 | return super 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /src/crometheus/metric.cr: -------------------------------------------------------------------------------- 1 | require "./sample" 2 | require "./registry" 3 | 4 | module Crometheus 5 | # `Metric` is the base class for individual metrics types. 6 | # 7 | # If you want to create your own custom metric types, you'll need to 8 | # subclass `Metric`. 9 | # `#samples` is the only abstract method you'll need to override; then 10 | # you will also need to implement your custom instrumentation. 11 | # You'll probably also want to define `.type` on your new class; it 12 | # should return a member of enum `Type`. 13 | # The following is a perfectly serviceable, if useless, metric type: 14 | # ``` 15 | # require "crometheus/metric" 16 | # 17 | # class Randometric < Crometheus::Metric 18 | # def self.type 19 | # Type::Gauge 20 | # end 21 | # 22 | # def samples(&block : Crometheus::Sample -> Nil) 23 | # yield Crometheus::Sample.new my_value_method 24 | # end 25 | # 26 | # def my_value_method 27 | # rand(10).to_f64 28 | # end 29 | # end 30 | # ``` 31 | # See the source to the `Counter`, `Gauge`, `Histogram`, and `Summary` 32 | # classes for more detailed examples of how to subclass `Metric`. 33 | abstract class Metric 34 | # The name of the metric. This will be converted to a `String` and 35 | # exported to Prometheus. 36 | getter name : Symbol 37 | # The docstring that appears in the `HELP` line of the exported 38 | # metric. 39 | getter docstring : String 40 | 41 | # Initializes `name` and `docstring`, then passes `self` to 42 | # `register_with.register` if not `nil`. 43 | # Raises an `ArgumentError` if `name` does not conform to 44 | # Prometheus' standards. 45 | def initialize(@name : Symbol, 46 | @docstring : String, 47 | register_with : Crometheus::Registry? = Crometheus.default_registry) 48 | unless name.to_s =~ /^[a-zA-Z_:][a-zA-Z0-9_:]*$/ 49 | raise ArgumentError.new("#{name} does not match [a-zA-Z_:][a-zA-Z0-9_:]*") 50 | end 51 | 52 | register_with.try &.register(self) 53 | end 54 | 55 | # Yields one `Sample` for each time series this metric represents. 56 | # Called by `Registry` to collect data for exposition. 57 | # Users generally do not need to call this. 58 | abstract def samples(&block : Sample -> Nil) : Nil 59 | 60 | # Returns the type of Prometheus metric this class represents. 61 | # Should be overridden to return the appropriate member of `Type`. 62 | # Called by `Registry` to determine metric type to report to 63 | # Prometheus. 64 | # Users generally do not need to call this. 65 | def self.type 66 | Type::Untyped 67 | end 68 | 69 | # Called by `#initialize` to validate that this `Metric`'s labels 70 | # do not violate any of Prometheus' naming rules. Returns `false` 71 | # under any of these conditions: 72 | # * the label is `:job` or `:instance` 73 | # * the label starts with `__` 74 | # * the label is not alphanumeric with underscores 75 | # 76 | # This generally does not need to be called manually. 77 | def self.valid_label?(label : Symbol) : Bool 78 | return false if [:job, :instance].includes?(label) 79 | ss = label.to_s 80 | return false if ss !~ /^[a-zA-Z_][a-zA-Z0-9_]*$/ 81 | return false if ss.starts_with?("__") 82 | return true 83 | end 84 | 85 | enum Type 86 | Gauge 87 | Counter 88 | Histogram 89 | Summary 90 | Untyped 91 | 92 | def to_s(io : IO) 93 | io << case self 94 | when .gauge? ; "gauge" 95 | when .counter? ; "counter" 96 | when .histogram?; "histogram" 97 | when .summary? ; "summary" 98 | else "untyped" 99 | end 100 | end 101 | end 102 | 103 | # `LabeledMetric` is a generic type that acts as a collection of 104 | # `Metric` objects exported under the same metric name, but with 105 | # different labelsets. 106 | # It takes two type parameters, one for the type of `Metric` to 107 | # to collect, and one `NamedTuple` mapping label names to `String` 108 | # values. 109 | # Since this is cumbersome to type out, you generally won't refer to 110 | # `LabeledMetric` directly in your code; instead, use `Metric.[]` or 111 | # `Crometheus.alias` to generate an appropriate `LabeledMetric` 112 | # type. 113 | # 114 | # Storing labelsets as `NamedTuple`s allows the compiler to enforce 115 | # the constraint that every metric in the collection have the same 116 | # set of label names. 117 | class LabeledMetric(LabelType, MetricType) < Metric 118 | @metrics : Hash(LabelType, MetricType) 119 | 120 | # Creates a `LabeledMetric`, saving `**metric_params` for passage 121 | # to the constructors of each metric in the collection. 122 | def initialize(name : Symbol, 123 | docstring : String, 124 | register_with : Crometheus::Registry? = Crometheus.default_registry, 125 | **metric_params) 126 | super(name, docstring, register_with) 127 | 128 | @metrics = Hash(LabelType, MetricType).new do |hh, kk| 129 | hh[kk] = MetricType.new(**metric_params, 130 | name: name, 131 | docstring: docstring, 132 | register_with: nil) 133 | end 134 | end 135 | 136 | # Fetches the metric with the given labelset. 137 | # Takes one keyword argument for each of this metric's labels. 138 | def [](**labels : **LabelType) 139 | @metrics[labels] 140 | end 141 | 142 | # Returns an array of every labelset currently ascribed to a 143 | # metric. 144 | def get_labels : Array(LabelType) 145 | return @metrics.keys 146 | end 147 | 148 | # Deletes the metric with the given labelset from the collection. 149 | # Takes one keyword argument for each of this metric's labels. 150 | def remove(**labels : **LabelType) 151 | @metrics.delete(labels) 152 | end 153 | 154 | # Deletes all metrics from the collection. 155 | def clear 156 | @metrics.clear 157 | end 158 | 159 | # Returns `MetricType.type`. See `Metric::Type`. 160 | def self.type 161 | MetricType.type 162 | end 163 | 164 | # Iteratively calls `samples` on each metric in the collection, 165 | # yielding each received `Sample`. 166 | # See `Metric#samples`. 167 | def samples(&block : Sample -> Nil) : Nil 168 | @metrics.each do |labels, metric| 169 | metric.samples { |ss| ss.labels.merge!(labels.to_h); yield ss } 170 | end 171 | return nil 172 | end 173 | end 174 | 175 | # Convenience macro for generating a `LabeledMetric` with 176 | # appropriate type parameters. 177 | # Takes any number of `Symbol`s as arguments, returning a 178 | # `LabeledMetric` class object with those arguments as label names. 179 | # Note that this macro causes type inference to fail when used with 180 | # class or instance variables; see `Crometheus.alias` for that use 181 | # case. 182 | # ``` 183 | # require "crometheus/gauge" 184 | # 185 | # ages = Crometheus::Gauge[:first_name, last_name].new(:age, "Age of person") 186 | # ages[first_name: "Jane", last_name: "Doe"].set 32 187 | # ages[first_name: "Sally", last_name: "Generic"].set 49 188 | # # ages[first_name: "Jay", middle_initial: "R", last_name: "Hacker"].set 46 189 | # # => compiler error; label names don't match. 190 | # ``` 191 | macro [](*labels) 192 | Crometheus::Metric::LabeledMetric( 193 | NamedTuple( 194 | {% for label in labels %} 195 | {{ label.id }}: String, 196 | {% end %} 197 | ), 198 | {{@type}} 199 | ) 200 | end 201 | end 202 | 203 | # Convenience macro for aliasing a constant identifier to a 204 | # `Metric::LabeledMetric` type. 205 | # Unfortunately, the `Metric.[]` macro breaks type inference when used to 206 | # initialize a class or instance variable. 207 | # This can be worked around by using this macro to generate a 208 | # friendly alias that allows the compiler to do type inference. 209 | # The syntax is exactly the same as the ordinary `alias` keyword, 210 | # except that the aliased type must be a `Metric::LabeledMetric` specified 211 | # with the notation documented in `Metric.[]`. 212 | # See 213 | # [Crystal issue #4039](https://github.com/crystal-lang/crystal/issues/4039) 214 | # for more information. 215 | # ``` 216 | # require "crometheus/counter" 217 | # 218 | # class TollBooth 219 | # Crometheus.alias CarCounter = Crometheus::Counter[:make, :model] 220 | # 221 | # def initialize 222 | # @money = Crometheus::Counter.new(:money, "Fees collected") 223 | # # Non-labeled metrics can be instantiated normally 224 | # @counts = CarCounter.new(:cars, "Number of cars driven") 225 | # end 226 | # 227 | # def count_car(make, model) 228 | # @money.inc(5) 229 | # @counts[make: make, model: model].inc 230 | # end 231 | # end 232 | # ``` 233 | macro alias(assignment) 234 | {% unless assignment.is_a?(Assign) && 235 | assignment.target.is_a?(Path) && 236 | assignment.value.is_a?(Call) && 237 | assignment.value.receiver.is_a?(Path) && 238 | assignment.value.name.stringify == "[]" %} 239 | {% raise "Crometheus aliases must take this form:\n`#{@type}.alias MyType = SomeMetricType[:label1, :label2, ... ]`" %} 240 | {% end %} 241 | alias {{ assignment.target }} = Crometheus::Metric::LabeledMetric( 242 | NamedTuple( 243 | {% for label in assignment.value.args %} 244 | {{ label.id }}: String, 245 | {% end %} 246 | ), 247 | {{ assignment.value.receiver }} 248 | ) 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /src/crometheus/middleware/http_collector.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | require "../registry" 3 | require "../counter" 4 | require "../histogram" 5 | 6 | module Crometheus 7 | # The Middleware module is reserved for definitions that are not 8 | # necessary for Crometheus to function but may be useful to 9 | # developers. 10 | module Middleware 11 | # This class is an `HTTP::Handler` that records basic statistics 12 | # about every request that comes in, and passes through to the next 13 | # handler without modifying the request or response. 14 | # It creates the `http_requests_total`, 15 | # `http_request_duration_seconds`, and 16 | # `http_request_exceptions_total` metrics. 17 | # See the `custom-server` example for a use case. 18 | class HttpCollector 19 | include HTTP::Handler 20 | 21 | Crometheus.alias RequestCounter = Crometheus::Counter[:code, :method, :path] 22 | Crometheus.alias DurationHistogram = Crometheus::Histogram[:method, :path] 23 | Crometheus.alias ExceptionCounter = Crometheus::Counter[:exception, :method, :path] 24 | 25 | # Transforms request paths before labeling metrics, so that 26 | # multiple paths may be tracked in the same time series. 27 | property path_cleaner : String -> String 28 | 29 | # Substitutes `":id"` in place of numeric path components, e.g. 30 | # turning `"/forum/102/thread/12"` into `"/forum/:id/thread/:id"`. 31 | DEFAULT_PATH_CLEANER = ->(path : String) { path.gsub(%r{/\d+(?=/|$)}, "/:id") } 32 | 33 | # Initializes the `HttpCollector`, allowing the user to set the 34 | # registry to which metrics will be added and the path cleaner. 35 | # Defaults to `Crometheus.default_registry` and 36 | # `DEFAULT_PATH_CLEANER`. 37 | # Set `path_cleaner` to `nil` to avoid mangling paths altogether. 38 | def initialize(@registry = Crometheus.default_registry, 39 | path_cleaner : (String -> String) | Nil = DEFAULT_PATH_CLEANER) 40 | @requests = RequestCounter.new( 41 | :http_requests_total, 42 | "The total number of HTTP requests handled by the application.", 43 | @registry) 44 | @durations = DurationHistogram.new( 45 | :http_request_duration_seconds, 46 | "The HTTP response duration of the application.", 47 | @registry) 48 | @exceptions = ExceptionCounter.new( 49 | :http_request_exceptions_total, 50 | "The total number of exceptions raised by the application.", 51 | @registry) 52 | @path_cleaner = path_cleaner || ->(path : String) { path } 53 | end 54 | 55 | # :nodoc: 56 | def call(context) 57 | method = context.request.method 58 | path = context.request.try &.path 59 | path = path ? path_cleaner.call(path) : "" 60 | 61 | @durations[method: method, path: path].measure_runtime do 62 | begin 63 | call_next(context) 64 | rescue ex 65 | @exceptions[exception: ex.class.to_s, method: method, path: path].inc 66 | raise ex 67 | end 68 | end 69 | 70 | @requests[code: context.response.status_code.to_s, method: method, path: path].inc 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /src/crometheus/registry.cr: -------------------------------------------------------------------------------- 1 | require "http/server" 2 | require "./metric" 3 | require "./stringify" 4 | require "./standard_exports" 5 | 6 | module Crometheus 7 | # The `Registry` class is responsible for aggregating samples from all 8 | # metrics and exposing data to Prometheus via an HTTP server. 9 | # 10 | # Crometheus automatically instantiates a `Registry` for you, and 11 | # `Metric` registers with it by default. This means that for most 12 | # applications, this is all it takes to get an HTTP server up and 13 | # serving: 14 | # ``` 15 | # require "crometheus/registry" 16 | # Crometheus.default_registry.host = "0.0.0.0" # defaults to "localhost" 17 | # Crometheus.default_registry.port = 12345 # defaults to 5000 18 | # Crometheus.default_registry.start_server 19 | # ``` 20 | class Registry 21 | # A list of all `Metric` objects being exposed by this registry. 22 | getter metrics = [] of Metric 23 | # The host that the server should bind to. 24 | # Defaults to `"localhost"`. 25 | property host = "localhost" 26 | # The port that the server should bind to. Defaults to `5000`. 27 | property port = 5000 28 | # If non-nil, will only handle requests with a matching path. 29 | property path : String | Regex | Nil = nil 30 | # If non-empty, will be prefixed to all metric names, separated by 31 | # an underscore. 32 | getter namespace = "" 33 | @server : HTTP::Server? = nil 34 | @server_on = false 35 | @handler : Handler? = nil 36 | 37 | # Creates a new `Registry`. 38 | # Will export standard process statistics by default by calling 39 | # `Crometheus.make_standard_exports`. 40 | # If you for some reason want to avoid this, set 41 | # `include_standard_exports` to `false`. 42 | def initialize(include_standard_exports = true) 43 | if include_standard_exports 44 | Crometheus.make_standard_exports(:process, "Standard process statistics", self) 45 | end 46 | end 47 | 48 | # Adds a `Metric` to this registry. The metric's samples will then 49 | # show up whenever the server is scraped. Metrics call `#register` 50 | # automatically in their constructors, so manual invocation is not 51 | # usually required. 52 | def register(metric) 53 | if @metrics.find { |mm| mm.name == metric.name } 54 | raise ArgumentError.new "Registered metrics must have unique names" 55 | end 56 | @metrics << metric 57 | @metrics.sort_by! { |mm| mm.name } 58 | end 59 | 60 | # Removes a `Metric` from the registry. The `Metric` keeps its 61 | # state and can be re-registered later. 62 | def unregister(metric) 63 | @metrics.delete(metric) 64 | end 65 | 66 | # Spawns a new fiber that serves HTTP connections on `host` and 67 | # `port`, then returns `true`. If the server is already running, 68 | # returns `false` without creating a new one. 69 | # 70 | # `#start_server` is included for convenience, but does not do any 71 | # exception handling. Serious applications should use `#run_server` 72 | # (which runs in the current fiber) instead, or call `#get_handler` 73 | # if they need more control over HTTP features.. 74 | def start_server 75 | return false if @server_on 76 | 77 | spawn do 78 | run_server 79 | end 80 | return true 81 | end 82 | 83 | # Stops the HTTP server, then returns `true`. If the server is not 84 | # running, returns `false` instead. 85 | def stop_server 86 | return false unless @server && @server_on 87 | @server.as(HTTP::Server).close 88 | Fiber.yield 89 | return true 90 | end 91 | 92 | # Creates an `HTTP::Server` object bound to `host` and `port` 93 | # and begins serving metrics as per `#get_handler`. 94 | # Returns `true` once the server is stopped. 95 | # Returns `false` immediately if this registry is already serving. 96 | def run_server 97 | return false if @server_on 98 | @server = server = HTTP::Server.new(get_handler) 99 | server.bind_tcp @host, @port 100 | @server_on = true 101 | begin 102 | server.listen 103 | ensure 104 | @server_on = false 105 | end 106 | return true 107 | end 108 | 109 | # Sets `namespace` to `str`, raising an `ArgumentError` if `str` is 110 | # not legal for a Prometheus metric name. 111 | def namespace=(str : String) 112 | unless str =~ /^[a-zA-Z_:][a-zA-Z0-9_:]*$/ || str.empty? 113 | raise ArgumentError.new("#{str} does not match [a-zA-Z_:][a-zA-Z0-9_:]*") 114 | end 115 | @namespace = str 116 | end 117 | 118 | protected def generate_text_format(io) 119 | prefix = namespace.empty? ? "" : namespace + "_" 120 | @metrics.each do |mm| 121 | io << "# HELP " << prefix << mm.name << ' ' << mm.docstring << '\n' 122 | io << "# TYPE " << prefix << mm.name << ' ' << 123 | (mm.class.type.as?(Metric::Type) || "untyped") << '\n' 124 | mm.samples do |sample| 125 | io << prefix << mm.name 126 | unless sample.suffix.empty? 127 | io << '_' << sample.suffix 128 | end 129 | unless sample.labels.empty? 130 | io << '{' << sample.labels.map { |kk, vv| "#{kk}=\"#{vv}\"" }.join(", ") << '}' 131 | end 132 | io << ' ' << Crometheus.stringify(sample.value) << '\n' 133 | end 134 | end 135 | end 136 | 137 | # Returns an `HTTP::Handler` that generates metrics. 138 | # If `path` is configured, and does not match the context path, 139 | # passes through to the next handler instead. 140 | def get_handler 141 | @handler ||= Handler.new(self) 142 | end 143 | 144 | private class Handler 145 | include HTTP::Handler 146 | 147 | def initialize(@registry : Registry) 148 | end 149 | 150 | def call(context) 151 | req_path = context.request.path 152 | return call_next(context) if (@registry.path.is_a? String && req_path != @registry.path) || 153 | (@registry.path.is_a? Regex && req_path !~ @registry.path) 154 | 155 | context.response.content_type = "text/plain; version=0.0.4" 156 | @registry.generate_text_format(context.response) 157 | end 158 | end 159 | end 160 | 161 | @@default_registry : Registry? = nil 162 | 163 | # Returns the default `Registry`. 164 | # All new `Metric` instances get registered to this by default. 165 | # 166 | # The first time this is called, setting `include_standard_exports` to 167 | # `false` will pass that argument to `Registry#new`. 168 | # This is done so that the user may exclude process statistics from 169 | # the default registry by calling `default_registry(false)` prior to 170 | # creating any metrics. 171 | def self.default_registry(include_standard_exports = true) 172 | @@default_registry ||= Registry.new(include_standard_exports) 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /src/crometheus/sample.cr: -------------------------------------------------------------------------------- 1 | module Crometheus 2 | # An instantaneous datum from a metric. `Metric` types 3 | # should yield one or more of these when `Metric#sample` is called. 4 | # `Registry` interpolates their values into an appropriate exposition 5 | # format. 6 | # 7 | # Each Sample corresponds to one line item in the exposed metric 8 | # data. Thus, counters and gauges always yield a single sample, while 9 | # summaries and histograms yield more depending on how many 10 | # buckets/quantiles are configured. 11 | # 12 | # A `Metric` named `:fruit` that yields a `Sample` like this: 13 | # ``` 14 | # Sample.new(12.0, labels: {:species => "banana"}, suffix: "count") 15 | # ``` 16 | # will produce an exported metric line like this: 17 | # ```text 18 | # fruit_count{species="banana"} 12.0 19 | # ``` 20 | struct Sample 21 | property suffix : String 22 | property value : Float64 23 | property labels : Hash(Symbol, String) 24 | 25 | # property timestamp : Int64? 26 | # https://groups.google.com/d/msg/prometheus-developers/p2SBdIbT4lQ/YYSQcpS0AgAJ 27 | 28 | def initialize(@value, @labels = {} of Symbol => String, @suffix = "") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/crometheus/standard_exports.cr: -------------------------------------------------------------------------------- 1 | require "./metric" 2 | require "./exceptions" 3 | 4 | lib LibC 5 | fun getpagesize : Int 6 | end 7 | 8 | module Crometheus 9 | abstract class Metric 10 | end 11 | 12 | # A `Metric` type that reports basic process statistics as given by 13 | # Crystal. 14 | # Generated by `Crometheus.make_standard_exports`. 15 | class StandardExports < Metric 16 | @start_time : Float64? 17 | 18 | def self.type 19 | Type::Gauge 20 | end 21 | 22 | def samples : Nil 23 | tms = Process.times 24 | gc_stats = GC.stats 25 | yield Sample.new(gc_stats.heap_size.to_f, suffix: "gc_heap_bytes") 26 | yield Sample.new(gc_stats.free_bytes.to_f, suffix: "gc_free_bytes") 27 | yield Sample.new(gc_stats.total_bytes.to_f, suffix: "gc_total_bytes") 28 | yield Sample.new(gc_stats.unmapped_bytes.to_f, suffix: "gc_unmapped_bytes") 29 | yield Sample.new(gc_stats.bytes_since_gc.to_f, suffix: "bytes_since_gc") 30 | yield Sample.new(tms.stime + tms.utime, suffix: "cpu_seconds_total") 31 | end 32 | 33 | # A subclass of `StandardExports` that also reports process 34 | # information from procfs. 35 | # Generated by `Crometheus.make_standard_exports`. 36 | class ProcFSExports < StandardExports 37 | def initialize(name : Symbol, 38 | docstring : String, 39 | register_with : Crometheus::Registry? = Crometheus.default_registry, 40 | @pid : Int64 = Process.pid, 41 | @procfs = "/proc") 42 | super(name, docstring, register_with) 43 | end 44 | 45 | def samples 46 | begin 47 | open_fds = 0 48 | Dir.each("#{@procfs}/#{@pid}/fd") do |node| 49 | open_fds += 1 unless "." == node || ".." == node 50 | end 51 | lines = [] of String 52 | File.each_line("#{@procfs}/#{@pid}/limits") { |line| lines << line } 53 | unless lines.find { |line| line =~ /^Max open files\s+(\d+)/ } 54 | raise Exceptions::InstrumentationError.new( 55 | "\"Max open files\" not found in #{@procfs}/#{@pid}/limits") 56 | end 57 | max_fds = $1.to_f 58 | parts = File.read("#{@procfs}/#{@pid}/stat").split(")")[-1].split 59 | virtual_memory = parts[20].to_f 60 | resident_memory = parts[21].to_f * self.class.page_size 61 | start_time = @start_time || begin 62 | jiffies = parts[19].to_f 63 | tick_rate = LibC.sysconf(LibC::SC_CLK_TCK) 64 | lines = [] of String 65 | File.each_line("#{@procfs}/stat") { |line| lines << line } 66 | unless lines.find { |line| line =~ /^btime\s+(\d+)/ } 67 | raise Exceptions::InstrumentationError.new("\"btime\" not found in #{@procfs}/stat") 68 | end 69 | boot_time = $1.to_f 70 | @start_time = (jiffies / tick_rate) + boot_time 71 | end 72 | rescue err : IO::Error 73 | raise Exceptions::InstrumentationError.new( 74 | "Error reading procfs: #{err.message}", err) 75 | rescue err : IndexError 76 | raise Exceptions::InstrumentationError.new( 77 | "Error reading procfs: #{@procfs}/#{@pid}/stat malformed?", err) 78 | end 79 | 80 | super { |sample| yield sample } 81 | 82 | yield Sample.new(open_fds.to_f, suffix: "open_fds") 83 | yield Sample.new(max_fds.to_f, suffix: "max_fds") 84 | yield Sample.new(virtual_memory.to_f, suffix: "virtual_memory_bytes") 85 | yield Sample.new(resident_memory.to_f, suffix: "resident_memory_bytes") 86 | yield Sample.new(start_time, suffix: "start_time_seconds") 87 | end 88 | 89 | protected def self.page_size 90 | @@page_size ||= LibC.getpagesize || 4096 91 | end 92 | end 93 | end 94 | 95 | # Checks for system capabilities and instantiates the appropriate 96 | # `StandardExports` type with the given arguments. 97 | # Will return an instance of either `StandardExports` or 98 | # `StandardExports::ProcFSExports`. 99 | # Unless disabled, each new `Registry` object will call this 100 | # automatically at creation. 101 | # See `Registry#new` and `default_registry`. 102 | def self.make_standard_exports(*args, **kwargs) 103 | pid = Process.pid 104 | if File.directory?("/proc/#{pid}/fd") && \ 105 | File.file?("/proc/#{pid}/limits") && \ 106 | File.file?("/proc/#{pid}/stat") && \ 107 | File.file?("/proc/stat") 108 | StandardExports::ProcFSExports.new(*args, **kwargs) 109 | else 110 | StandardExports.new(*args, **kwargs) 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /src/crometheus/stringify.cr: -------------------------------------------------------------------------------- 1 | module Crometheus 2 | # Represents NaN's and infinities in the same format as Prometheus. 3 | # Prometheus represents infinities as "+Inf" or "-Inf" 4 | # and NaN's as "NaN". We want to use those representations instead of 5 | # the ones Crystal uses, to ensure compatability and minimize surprise 6 | # in histogram labels. 7 | # Note that this only performs a conversion to String if the Float64 8 | # has one of the mentioned values; this is to avoid extra String 9 | # allocations in use cases like `io << stringify(my_float)`. 10 | # If you want a guaranteed String returned you'll still need to use 11 | # `to_s` on the result. 12 | def self.stringify(ff : Float64) : String | Float64 13 | case ff 14 | when Float64::INFINITY 15 | "+Inf" 16 | when -Float64::INFINITY 17 | "-Inf" 18 | when ff 19 | ff 20 | else 21 | "NaN" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/crometheus/summary.cr: -------------------------------------------------------------------------------- 1 | require "./metric" 2 | 3 | module Crometheus 4 | # `Summary` is a metric type that keeps a running total of 5 | # observations. Every time `#observe` is called, `sum` is incremented 6 | # by the given value, and `count` is incremented by one. 7 | # 8 | # Quantiles are not currently supported. 9 | # ``` 10 | # require "crometheus/summary" 11 | # 12 | # cargo_weight = Crometheus::Summary.new( 13 | # :cargo_weight, "Weight of all boxes") 14 | # cargo_weight.observe 29.0 15 | # cargo_weight.observe 12.0 16 | # cargo_weight.observe 10.0 17 | # 18 | # mean_weight = cargo_weight.sum / cargo_weight.count # => 17.0 19 | # ``` 20 | class Summary < Metric 21 | # The total number of observations. 22 | getter count = 0.0 23 | # The sum of all observed values. 24 | getter sum = 0.0 25 | 26 | # Increments `count` by one and `sum` by `value`. 27 | def observe(value : Int8 | Int16 | Int32 | Int64 | Int128 | Float32 | Float64) 28 | @count += 1.0 29 | @sum += value.to_f64 30 | end 31 | 32 | # Sets `count` and `sum` to `0.0`. 33 | def reset 34 | @count = 0.0 35 | @sum = 0.0 36 | end 37 | 38 | # Yields to the block, then passes the block's runtime to 39 | # `#observe`. 40 | def measure_runtime(&block) 41 | t0 = Time.utc 42 | begin 43 | yield 44 | ensure 45 | t1 = Time.utc 46 | observe((t1 - t0).to_f) 47 | end 48 | end 49 | 50 | # Yields two samples, one for `count` and one for `sum`. See `Metric#samples`. 51 | def samples(&block : Sample -> Nil) : Nil 52 | yield Sample.new(@count, suffix: "count") 53 | yield Sample.new(@sum, suffix: "sum") 54 | end 55 | 56 | # Returns `Type::Summary`. See `Metric.type`. 57 | def self.type 58 | Type::Summary 59 | end 60 | 61 | # In addition to the standard `Metric.valid_label?` behavior, 62 | # returns `false` if a label is `:quantile`. Histograms reserve this 63 | # label for exporting quantiles (currently unsupported by 64 | # Crometheus). 65 | def self.valid_label?(label : Symbol) 66 | return false if :quantile == label 67 | return super 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/crometheus/version.cr: -------------------------------------------------------------------------------- 1 | module Crometheus 2 | VERSION = "0.2.0" 3 | end 4 | --------------------------------------------------------------------------------