├── .editorconfig ├── .github └── workflows │ ├── maintenance-cache-wipe.yml │ ├── maintenance-workflow-cleanup.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── examples ├── app-perf-dark.png ├── app-perf-light.png ├── controller-dark.png ├── controller-light.png ├── jobs-dark.png ├── jobs-light.png ├── queue-time-dark.png └── queue-time-light.png ├── gemfiles ├── rack_2.0.gemfile ├── rack_2.1.gemfile ├── rack_2.2.gemfile ├── rack_3.gemfile ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile ├── rails_7.2.gemfile ├── rails_8.0.gemfile ├── sidekiq_6.gemfile ├── sidekiq_7.gemfile └── sidekiq_8.gemfile ├── lib ├── telegraf.rb └── telegraf │ ├── active_job.rb │ ├── agent.rb │ ├── grape.rb │ ├── plugin.rb │ ├── rack.rb │ ├── rails.rb │ ├── railtie.rb │ ├── serializer.rb │ ├── sidekiq.rb │ └── version.rb ├── renovate.json5 ├── spec ├── spec_helper.rb ├── telegraf │ ├── active_job_spec.rb │ ├── agent_spec.rb │ ├── rack_spec.rb │ ├── railtie_spec.rb │ ├── serializer_spec.rb │ └── sidekiq_spec.rb └── telegraf_spec.rb └── telegraf.gemspec /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/workflows/maintenance-cache-wipe.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: maintenance-cache-clear 3 | on: 4 | schedule: 5 | - cron: "0 0 1 * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | cache-clear: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: easimon/wipe-cache@v2 14 | -------------------------------------------------------------------------------- /.github/workflows/maintenance-workflow-cleanup.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: maintenance-workflow-cleanup 3 | on: 4 | schedule: 5 | - cron: "0 0 1 * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | delete-workflow-runs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: Mattraks/delete-workflow-runs@v2 13 | with: 14 | token: ${{ github.token }} 15 | repository: ${{ github.repository }} 16 | retain_days: 180 17 | keep_minimum_runs: 50 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # vim: ft=yaml 2 | 3 | name: release 4 | 5 | on: 6 | push: 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | rubygems: 12 | if: github.repository == 'jgraichen/telegraf-ruby' 13 | runs-on: ubuntu-24.04 14 | 15 | permissions: 16 | contents: write 17 | id-token: write 18 | 19 | env: 20 | BUNDLE_JOBS: 4 21 | BUNDLE_RETRY: 10 22 | BUNDLE_WITHOUT: development test 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ruby 30 | bundler-cache: true 31 | 32 | - uses: rubygems/release-gem@v1 33 | 34 | - uses: taiki-e/create-gh-release-action@v1 35 | with: 36 | changelog: CHANGELOG.md 37 | draft: true 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # vim: ft=yaml 2 | name: test 3 | 4 | on: 5 | push: 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | rspec: 11 | name: Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }} 12 | runs-on: ubuntu-24.04 13 | 14 | strategy: 15 | matrix: 16 | ruby: 17 | - "3.4" 18 | - "3.3" 19 | - "3.2" 20 | - "3.1" 21 | gemfile: 22 | - rails_8.0.gemfile 23 | - rails_7.2.gemfile 24 | - rails_7.1.gemfile 25 | - rails_7.0.gemfile 26 | - rails_6.1.gemfile 27 | - rack_3.gemfile 28 | - rack_2.2.gemfile 29 | - rack_2.1.gemfile 30 | - rack_2.0.gemfile 31 | - sidekiq_6.gemfile 32 | - sidekiq_7.gemfile 33 | - sidekiq_8.gemfile 34 | exclude: 35 | - ruby: "3.4" 36 | gemfile: rails_7.0.gemfile 37 | - ruby: "3.4" 38 | gemfile: rails_6.1.gemfile 39 | - ruby: "3.4" 40 | gemfile: rack_2.1.gemfile 41 | - ruby: "3.4" 42 | gemfile: rack_2.0.gemfile 43 | - ruby: "3.1" 44 | gemfile: rails_8.0.gemfile 45 | - ruby: "3.1" 46 | gemfile: sidekiq_8.gemfile 47 | fail-fast: false 48 | 49 | env: 50 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }} 51 | BUNDLE_WITHOUT: development 52 | BUNDLE_JOBS: 4 53 | BUNDLE_RETRY: 3 54 | 55 | steps: 56 | - uses: actions/checkout@master 57 | 58 | - uses: ruby/setup-ruby@v1 59 | with: 60 | ruby-version: ${{ matrix.ruby }} 61 | bundler-cache: true 62 | 63 | - run: bundle exec rspec --color --format documentation 64 | 65 | rubocop: 66 | name: rubocop 67 | runs-on: ubuntu-24.04 68 | 69 | env: 70 | BUNDLE_WITHOUT: development 71 | BUNDLE_JOBS: 4 72 | BUNDLE_RETRY: 3 73 | 74 | steps: 75 | - uses: actions/checkout@master 76 | - uses: ruby/setup-ruby@v1 77 | with: 78 | ruby-version: '3.4' 79 | bundler-cache: true 80 | 81 | - run: bundle exec rubocop --parallel --color 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_yardoc/ 2 | /.bundle/ 3 | /.yardoc 4 | /coverage/ 5 | /doc/ 6 | /Gemfile.lock 7 | /gemfiles/.bundle/ 8 | /gemfiles/*.lock 9 | /log/ 10 | /pkg/ 11 | /spec/reports/ 12 | /tmp/ 13 | 14 | # rspec failure tracking 15 | .rspec_status 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # vim: ft=yaml 2 | 3 | inherit_gem: 4 | rubocop-config: default.yml 5 | 6 | AllCops: 7 | TargetRubyVersion: "2.7" 8 | SuggestExtensions: False 9 | 10 | RSpec/SpecFilePathFormat: 11 | Enabled: False 12 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-8.0' do 4 | group :test do 5 | gem 'rails', '~> 8.0.0' 6 | end 7 | end 8 | 9 | appraise 'rails-7.2' do 10 | group :test do 11 | gem 'rails', '~> 7.2.0' 12 | end 13 | end 14 | 15 | appraise 'rails-7.1' do 16 | group :test do 17 | gem 'rails', '~> 7.1.0' 18 | end 19 | end 20 | 21 | appraise 'rails-7.0' do 22 | group :test do 23 | gem 'rails', '~> 7.0.0' 24 | end 25 | end 26 | 27 | appraise 'rails-6.1' do 28 | group :test do 29 | gem 'rails', '~> 6.1.0' 30 | end 31 | end 32 | 33 | appraise 'rack-3' do 34 | group :test do 35 | gem 'rack', '~> 3.0' 36 | end 37 | end 38 | 39 | appraise 'rack-2.2' do 40 | group :test do 41 | gem 'rack', '~> 2.2.0' 42 | end 43 | end 44 | 45 | appraise 'rack-2.1' do 46 | group :test do 47 | gem 'rack', '~> 2.1.0' 48 | end 49 | end 50 | 51 | appraise 'rack-2.0' do 52 | group :test do 53 | gem 'rack', '~> 2.0.0' 54 | end 55 | end 56 | 57 | appraise 'sidekiq-6' do 58 | group :test do 59 | gem 'sidekiq', '~> 6.0' 60 | end 61 | end 62 | 63 | appraise 'sidekiq-7' do 64 | group :test do 65 | gem 'sidekiq', '~> 7.0' 66 | end 67 | end 68 | 69 | appraise 'sidekiq-8' do 70 | group :test do 71 | gem 'sidekiq', '~> 8.0' 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | ## [Unreleased] 8 | 9 | ## [3.2.0] - 2025-03-08 10 | 11 | ### Added 12 | 13 | - Support for Sidekiq 8 14 | 15 | ## [3.1.0] - 2025-01-31 16 | 17 | ### Added 18 | 19 | - Support for Ruby 3.4, Rails 8.0, Rack 3 20 | 21 | ## [3.0.0] - 2024-03-11 22 | 23 | ### Added 24 | 25 | - New standalone Influx line protocol serializer 26 | - `before_send` filter option for agent and all plugins (#27) 27 | 28 | ### Changed 29 | 30 | - Require Ruby 2.7+ 31 | 32 | ## [2.1.1] - 2022-08-23 33 | 34 | ### Fixed 35 | 36 | - Possible RuntimeError: can't add a new key into hash during iteration (@MrSerth) 37 | 38 | ## [2.1.0] - 2022-01-24 39 | 40 | ### Added 41 | 42 | - Support for Rails 7.0 and Ruby 3.1 43 | - Grape API instrumentation (#17) 44 | 45 | ## [2.0.0] - 2021-09-30 46 | 47 | ### Changed 48 | 49 | - The sidekiq middleware does not use keyword arguments as sidekiq does not handle them correctly on Ruby 3.0 (#14) 50 | 51 | ## [1.0.0] - 2021-01-26 52 | 53 | ### Added 54 | 55 | - Global tags (#6) 56 | 57 | ## [0.8.0] - 2020-12-02 58 | 59 | ### Added 60 | 61 | - ActiveJob instrumentation (#10) 62 | 63 | ## [0.7.0] - 2020-05-07 64 | 65 | ### Added 66 | 67 | - Sidekiq middleware (#8) 68 | 69 | ## [0.6.1] - 2020-04-01 70 | 71 | ### Fixed 72 | 73 | - Fix type in instrumentation option (#7) 74 | 75 | ## [0.6.0] - 2020-03-31 76 | 77 | ### Added 78 | 79 | - New Rack middleware and Rails plugin to collect request events (#5) 80 | 81 | ## 0.5.0 - undefined 82 | 83 | ### Changed 84 | 85 | - Remove `influxdb` not unnecessarily restrict users needing a specific influxdb client. 86 | 87 | [Unreleased]: https://github.com/jgraichen/telegraf-ruby/compare/v3.2.0...HEAD 88 | [3.2.0]: https://github.com/jgraichen/telegraf-ruby/compare/v3.1.0...v3.2.0 89 | [3.1.0]: https://github.com/jgraichen/telegraf-ruby/compare/v3.0.0...v3.1.0 90 | [3.0.0]: https://github.com/jgraichen/telegraf-ruby/compare/v2.1.1...v3.0.0 91 | [2.1.1]: https://github.com/jgraichen/telegraf-ruby/compare/v2.1.0...v2.1.1 92 | [2.1.0]: https://github.com/jgraichen/telegraf-ruby/compare/v2.0.0...v2.1.0 93 | [2.0.0]: https://github.com/jgraichen/telegraf-ruby/compare/v1.0.0...v2.0.0 94 | [1.0.0]: https://github.com/jgraichen/telegraf-ruby/compare/v0.8.0...v1.0.0 95 | [0.8.0]: https://github.com/jgraichen/telegraf-ruby/compare/v0.7.0...v0.8.0 96 | [0.7.0]: https://github.com/jgraichen/telegraf-ruby/compare/v0.6.1...v0.7.0 97 | [0.6.1]: https://github.com/jgraichen/telegraf-ruby/compare/v0.6.0...v0.6.1 98 | [0.6.0]: https://github.com/jgraichen/telegraf-ruby/compare/v0.5.0...v0.6.0 99 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in telegraf.gemspec 6 | gemspec 7 | 8 | gem 'rake' 9 | gem 'rake-release', '~> 1.2' 10 | 11 | gem 'rspec', '~> 3.8' 12 | gem 'rspec-github', require: false 13 | 14 | gem 'rubocop-config', github: 'jgraichen/rubocop-config', tag: 'v14' 15 | 16 | group :test do 17 | gem 'rack' 18 | gem 'rails' 19 | gem 'sidekiq' 20 | end 21 | 22 | group :development do 23 | gem 'appraisal' 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegraf 2 | 3 | [![Gem Version](https://img.shields.io/gem/v/telegraf?logo=ruby)](https://rubygems.org/gems/telegraf) 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jgraichen/telegraf-ruby/test.yml?logo=github)](https://github.com/jgraichen/telegraf-ruby/actions) 5 | 6 | Send events to a local [Telegraf](https://github.com/influxdata/telegraf) agent or anything that can receive the InfluxDB line protocol. 7 | 8 | It further includes plugins for Rack, Rails, ActiveJob and Sidekiq to collect request events. See plugin usage details below. 9 | 10 | ## Examples 11 | 12 | 13 | 14 | 22 | 30 | 31 | 32 | 40 | 48 | 49 |
15 | 16 | 17 | 18 | Screenshot of a Grafana dashboard showing overview metrics about the application controllers performance such as slow actions, controller runtime breakdown, total time consumption 19 | 20 | 21 | 23 | 24 | 25 | 26 | Screenshot of a Grafana dashboard showing detailed metrics about an individual controller actions including a flamegraph for request performance 27 | 28 | 29 |
33 | 34 | 35 | 36 | Screenshot of a Grafana dashboard showing detailed queue time metrics including percentiles and median for multiple applications 37 | 38 | 39 | 41 | 42 | 43 | 44 | Screenshot of a Grafana dashboard showing metrics about sidekiq background jobs such as runtime and errors 45 | 46 | 47 |
50 | 51 | ## Installation 52 | 53 | ```ruby 54 | gem 'telegraf' 55 | ``` 56 | 57 | And then execute: 58 | 59 | ```console 60 | bundle 61 | ``` 62 | 63 | Or install it yourself as: 64 | 65 | ```ruby 66 | gem install telegraf 67 | ``` 68 | 69 | ## Usage as a library 70 | 71 | Configure telegraf socket listener e.g.: 72 | 73 | ```toml 74 | [[inputs.socket_listener]] 75 | service_address = "udp://localhost:8094" 76 | ``` 77 | 78 | ```ruby 79 | telegraf = Telegraf::Agent.new 'udp://localhost:8094' 80 | telegraf = Telegraf::Agent.new # default: 'udp://localhost:8094' 81 | 82 | telegraf.write('demo', 83 | tags: {tag_a: 'A', tag_b: 'B'}, 84 | values: {value_a: 1, value_b: 1.5}) 85 | 86 | telegraf.write([{ 87 | series: 'demo', 88 | tags: {tag_a: 'A', tag_b: 'B'}, 89 | values: {value_a: 1, value_b: 1.5} 90 | }]) 91 | ``` 92 | 93 | There is no buffer or batch handling, nor connection pooling or keep alive. Each `#write` creates a new connection (unless it's a datagram connection). 94 | 95 | There is no exception handling. 96 | 97 | ## Using the Rack and Rails plugins 98 | 99 | This gem includes a Rails plugin and middlewares / adapters for Rack, ActiveJob and Sidekiq, to collect request and background worker events. You need to require them explicitly: 100 | 101 | ### Rack 102 | 103 | ```ruby 104 | require "telegraf/rack" 105 | 106 | agent = ::Telegraf::Agent.new 107 | use ::Telegraf::Rack.new(series: 'rack', agent: agent, tags: {global: 'tag'}) 108 | ``` 109 | 110 | See middleware [class documentation](lib/telegraf/rack.rb) for more details. 111 | 112 | The Rack middleware supports parsing the `X-Request-Start: t=` header expecting a fractional (UTC) timestamp when the request has been started or first received by e.g. a load balancer. An additional value `queue_ms` with the queue time will be included. 113 | 114 | ### Rails 115 | 116 | The Rails plugin needs to be required, too, but will automatically install additional components (Rack, ActiveJob, Sidekiq and Rails-specific instrumentation). 117 | 118 | ```ruby 119 | # e.g. in application.rb 120 | 121 | # Load rails plugin (!) or add `require: 'telegraf/rails'` to Gemfile 122 | require "telegraf/rails" 123 | 124 | class MyApplication > ::Rails::Application 125 | # Configure receiver 126 | config.telegraf.connect = "udp://localhost:8094" 127 | 128 | # Global tags added to all events. These will override 129 | # any local tag with the same name. 130 | config.telegraf.tags = {} 131 | 132 | # By default the Rack middleware to collect events is installed 133 | config.telegraf.rack.enabled = true 134 | config.telegraf.rack.series = "requests" 135 | config.telegraf.rack.tags = {} 136 | 137 | # These are the default settings when ActiveJob is detected 138 | config.telegraf.active_job.enabled = true 139 | config.telegraf.active_job.series = "active_job" 140 | config.telegraf.active_job.tags = {} 141 | 142 | # These are the default settings when Sidekiq is detected 143 | config.telegraf.sidekiq.enabled = true 144 | config.telegraf.sidekiq.series = "sidekiq" 145 | config.telegraf.sidekiq.tags = {} 146 | 147 | # Additionally the application is instrumented to tag events with 148 | # controller and action as well as to collect app, database and view timings 149 | config.telegraf.instrumentation = true 150 | end 151 | ``` 152 | 153 | Received event example: 154 | 155 | ```text 156 | requests,action=index,controller=TestController,instance=TestController#index,method=GET,status=200 db_ms=0.0,view_ms=2.6217450003969134,action_ms=2.702335,app_ms=4.603561000294576,send_ms=0.09295000018028077,request_ms=4.699011000411701,queue_ms=0.00003000028323014 157 | ``` 158 | 159 | See the various classes' documentation for more details on the collected tags and values: 160 | 161 | - [Rack middleware](lib/telegraf/rack.rb) 162 | - [Rails plugin](lib/telegraf/railtie.rb) 163 | - [ActiveJob plugin](lib/telegraf/active_job.rb) 164 | - [Sidekiq middleware](lib/telegraf/sidekiq.rb) 165 | 166 | ### ActiveJob 167 | 168 | ```ruby 169 | require "telegraf/active_job" 170 | 171 | agent = ::Telegraf::Agent.new 172 | ActiveSupport::Notifications.subscribe( 173 | 'perform.active_job', 174 | Telegraf::ActiveJob.new(agent: agent, series: 'active_job', tags: {global: 'tag'}) 175 | ) 176 | ``` 177 | 178 | See plugin [class documentation](lib/telegraf/active_job.rb) for more details. 179 | 180 | ### Sidekiq 181 | 182 | ```ruby 183 | require "telegraf/sidekiq" 184 | 185 | agent = ::Telegraf::Agent.new 186 | Sidekiq.configure_server do |config| 187 | config.server_middleware do |chain| 188 | chain.add ::Telegraf::Sidekiq::Middleware, agent, series: 'sidekiq', tags: {global: 'tag'} 189 | end 190 | end 191 | ``` 192 | 193 | See middleware [class documentation](lib/telegraf/sidekiq.rb) for more details. 194 | 195 | ## License 196 | 197 | Copyright (C) 2017-2024 Jan Graichen 198 | 199 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 200 | 201 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 202 | 203 | You should have received a copy of the GNU Lesser General Public License along with this program. If not, see . 204 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake/release/task' 4 | require 'rspec/core/rake_task' 5 | 6 | Rake::Release::Task.new do |spec| 7 | spec.sign_tag = true 8 | end 9 | 10 | RSpec::Core::RakeTask.new(:spec) 11 | 12 | task default: :spec 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'telegraf' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/app-perf-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/app-perf-dark.png -------------------------------------------------------------------------------- /examples/app-perf-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/app-perf-light.png -------------------------------------------------------------------------------- /examples/controller-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/controller-dark.png -------------------------------------------------------------------------------- /examples/controller-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/controller-light.png -------------------------------------------------------------------------------- /examples/jobs-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/jobs-dark.png -------------------------------------------------------------------------------- /examples/jobs-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/jobs-light.png -------------------------------------------------------------------------------- /examples/queue-time-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/queue-time-dark.png -------------------------------------------------------------------------------- /examples/queue-time-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgraichen/telegraf-ruby/956d195cb95564db3bbe246b85480eec4ced3240/examples/queue-time-light.png -------------------------------------------------------------------------------- /gemfiles/rack_2.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack", "~> 2.0.0" 13 | gem "rails" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rack_2.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack", "~> 2.1.0" 13 | gem "rails" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rack_2.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack", "~> 2.2.0" 13 | gem "rails" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rack_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack", "~> 3.0" 13 | gem "rails" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails", "~> 6.1.0" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails", "~> 7.0.0" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails", "~> 7.1.0" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails", "~> 7.2.0" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails", "~> 8.0.0" 14 | gem "sidekiq" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails" 14 | gem "sidekiq", "~> 6.0" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails" 14 | gem "sidekiq", "~> 7.0" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "rake-release", "~> 1.2" 7 | gem "rspec", "~> 3.8" 8 | gem "rspec-github", require: false 9 | gem "rubocop-config", github: "jgraichen/rubocop-config", tag: "v13" 10 | 11 | group :test do 12 | gem "rack" 13 | gem "rails" 14 | gem "sidekiq", "~> 8.0" 15 | end 16 | 17 | group :development do 18 | gem "appraisal" 19 | end 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /lib/telegraf.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'telegraf/version' 4 | 5 | module Telegraf 6 | require 'telegraf/agent' 7 | require 'telegraf/plugin' 8 | end 9 | -------------------------------------------------------------------------------- /lib/telegraf/active_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Telegraf 4 | # Telegraf::ActiveJob 5 | # 6 | # This class collects ActiveJob queue metrics and sends them to telegraf. 7 | # 8 | # 9 | # Tags: 10 | # 11 | # * `queue`: 12 | # The queue this job landed on. 13 | # 14 | # * `job`: 15 | # The name of the job class that was executed. 16 | # 17 | # * `errors`: 18 | # Whether or not this job errored. 19 | # 20 | # 21 | # Values: 22 | # 23 | # * `app_ms`: 24 | # Total job processing time. 25 | # 26 | class ActiveJob 27 | include Plugin 28 | 29 | def initialize(series: 'active_job', **kwargs) 30 | super 31 | end 32 | 33 | def call(_name, start, finish, _id, payload) 34 | job = payload[:job] 35 | 36 | point = Point.new( 37 | tags: { 38 | **@tags, 39 | job: job.class.name, 40 | queue: job.queue_name, 41 | errors: payload.key?(:exception_object), 42 | }, 43 | values: { 44 | app_ms: ((finish - start) * 1000.0), # milliseconds 45 | }, 46 | ) 47 | 48 | _write(point, before_send_kwargs: {payload: payload}) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/telegraf/agent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'telegraf/serializer' 5 | 6 | module Telegraf 7 | class Agent 8 | DEFAULT_CONNECTION = 'udp://localhost:8094' 9 | 10 | attr_reader :uri, :logger, :tags 11 | 12 | def initialize(uri = nil, logger: nil, tags: {}, before_send: nil) 13 | @uri = URI.parse(uri || DEFAULT_CONNECTION) 14 | @tags = tags 15 | @logger = logger 16 | @serializer = Serializer.new 17 | @before_send = before_send 18 | end 19 | 20 | def write(*args, **kwargs) 21 | write!(*args, **kwargs) 22 | rescue StandardError => e 23 | logger&.error('telegraf') do 24 | e.to_s + e.backtrace.join("\n") 25 | end 26 | end 27 | 28 | def write!(data, series: nil, tags: nil, values: nil) 29 | tags = tags.merge(@tags) unless @tags.empty? 30 | 31 | if values 32 | data = [{series: series || data.to_s, tags: tags, values: values.dup}] 33 | end 34 | 35 | if @before_send 36 | data = @before_send.call(data) 37 | return unless data 38 | end 39 | 40 | socket = connect @uri 41 | socket.write(@serializer.dump_all(data)) 42 | ensure 43 | socket&.close 44 | end 45 | 46 | private 47 | 48 | def connect(uri) 49 | case uri.scheme.downcase 50 | when 'unix' 51 | Socket.new(:UNIX, :STREAM).tap do |socket| 52 | socket.connect(Socket.pack_sockaddr_un(uri.path)) 53 | end 54 | when 'unixgram' 55 | Socket.new(:UNIX, :DGRAM).tap do |socket| 56 | socket.connect(Socket.pack_sockaddr_un(uri.path)) 57 | end 58 | when 'tcp', 'tcp4', 'tcp6' 59 | TCPSocket.new uri.host, uri.port 60 | when 'udp', 'udp4', 'udp6' 61 | UDPSocket.new.tap do |socket| 62 | socket.connect uri.host, uri.port 63 | end 64 | else 65 | raise "Unknown connection type: #{uri.scheme}" 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/telegraf/grape.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Telegraf 4 | # Telegraf::Grape 5 | # 6 | # This class extends requests metrics with details for Grape API endpoints. 7 | # 8 | # 9 | # Tags: 10 | # 11 | # * `controller`: 12 | # The Grape endpoint class. 13 | # 14 | # * `instance`: 15 | # The Grape endpoint class. 16 | # 17 | # * `format`: 18 | # Grape's internal identifier for the response format. 19 | # 20 | class Grape 21 | def call(_name, _start, _finish, _id, payload) 22 | point = payload[:env][::Telegraf::Rack::FIELD_NAME] 23 | return unless point 24 | 25 | endpoint = payload[:endpoint] 26 | return unless endpoint 27 | 28 | point.tags[:controller] = endpoint.options[:for].to_s 29 | point.tags[:instance] = point.tags[:controller] 30 | point.tags[:format] = payload[:env]['api.format'] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/telegraf/plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | module Telegraf 6 | module Plugin 7 | # Warning: `:values` member overrides `Struct#values` and it may be 8 | # unexpected, but nothing we can change here as this is an import public API 9 | # right now. 10 | # 11 | # rubocop:disable Lint/StructNewOverride 12 | Point = Struct.new(:tags, :values, keyword_init: true) do 13 | def initialize(tags: {}, values: {}) 14 | super 15 | end 16 | end 17 | # rubocop:enable Lint/StructNewOverride 18 | 19 | def initialize(agent:, series:, tags: {}, before_send: nil, **) 20 | @agent = agent 21 | 22 | @tags = tags.freeze 23 | @series = String(series).freeze 24 | @before_send = before_send 25 | end 26 | 27 | def _write(point, before_send_kwargs: {}) 28 | if @before_send 29 | point = @before_send.call(point, **before_send_kwargs) 30 | return unless point 31 | end 32 | 33 | @agent.write(@series, tags: point.tags, values: point.values) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/telegraf/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | module Telegraf 6 | # Telegraf::Rack 7 | # 8 | # This rack middleware collects request metrics and sends them to the telegraf 9 | # agent. A `Point` data structure is added to the Rack environment to assign 10 | # custom tags and values. This point can be accessed using the environment key 11 | # defined in `::Telegraf::Rack::FIELD_NAME`. 12 | # 13 | # Example: 14 | # 15 | # if (point = request.env[::Telegraf::Rack::FIELD_NAME]) 16 | # point.tags[:tag] = 'tag' 17 | # point.values[:value] = 10 18 | # end 19 | # 20 | # 21 | # Tags: 22 | # 23 | # * `status`: 24 | # Response status unless request errored 25 | # 26 | # 27 | # Values: 28 | # 29 | # * `request_ms`: 30 | # Total request processing time including response sending. 31 | # 32 | # * `app_ms`: 33 | # Total application processing time. 34 | # 35 | # * `send_ms`: 36 | # Time took to send the response body. 37 | # 38 | # * `queue_ms`: 39 | # Queue time calculated from a `X-Request-Start` header if present. The 40 | # header is expected to be formatted like this `t=` and 41 | # contain a floating point timestamp in seconds. 42 | # 43 | class Rack 44 | include ::Telegraf::Plugin 45 | 46 | FIELD_NAME = 'telegraf.rack.point' 47 | HEADER_REGEX = /t=(\d+(\.\d+)?)/.freeze 48 | 49 | def initialize(app, agent:, series: 'rack', tags: {}, logger: nil, before_send: nil) # rubocop:disable Metrics/ParameterLists 50 | super(agent: agent, series: series, tags: tags, before_send: before_send) 51 | 52 | @app = app 53 | @logger = logger 54 | end 55 | 56 | def call(env) 57 | if (request_start = extract_request_start(env)) 58 | queue_ms = (::Time.now.utc - request_start) * 1000 # milliseconds 59 | end 60 | 61 | rack_start = ::Rack::Utils.clock_time 62 | point = env[FIELD_NAME] = Point.new(tags: @tags.dup) 63 | point.values[:queue_ms] = queue_ms if queue_ms 64 | 65 | begin 66 | begin 67 | status, headers, body = @app.call(env) 68 | ensure 69 | point.tags[:status] ||= status || -1 70 | point.values[:app_ms] = 71 | (::Rack::Utils.clock_time - rack_start) * 1000 # milliseconds 72 | end 73 | 74 | send_start = ::Rack::Utils.clock_time 75 | proxy = ::Rack::BodyProxy.new(body) do 76 | point.values[:send_ms] = 77 | (::Rack::Utils.clock_time - send_start) * 1000 # milliseconds 78 | 79 | finish(env, point, rack_start) 80 | end 81 | 82 | [status, headers, proxy] 83 | ensure 84 | finish(env, point, rack_start) unless proxy 85 | end 86 | end 87 | 88 | private 89 | 90 | def finish(env, point, rack_start) 91 | point.values[:request_ms] = 92 | (::Rack::Utils.clock_time - rack_start) * 1000 # milliseconds 93 | 94 | _write(point, before_send_kwargs: {request: ::Rack::Request.new(env)}) 95 | rescue StandardError => e 96 | (@logger || env[::Rack::RACK_LOGGER])&.error(e) 97 | end 98 | 99 | def extract_request_start(env) 100 | return unless env.key?('HTTP_X_REQUEST_START') 101 | 102 | if (m = HEADER_REGEX.match(env['HTTP_X_REQUEST_START'])) 103 | ::Time.at(m[1].to_f).utc 104 | end 105 | rescue FloatDomainError 106 | # Ignore obscure floats in Time.at (e.g. infinity) 107 | false 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/telegraf/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'telegraf/railtie' 4 | -------------------------------------------------------------------------------- /lib/telegraf/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails' 4 | require 'telegraf' 5 | require 'telegraf/active_job' 6 | require 'telegraf/grape' 7 | require 'telegraf/rack' 8 | require 'telegraf/sidekiq' 9 | 10 | module Telegraf 11 | # Telegraf::Railtie 12 | # 13 | # This Rails plugin installs the rack middleware and adds intrumentation to 14 | # enrich the data point with additional tags an values. 15 | # 16 | # These include the following tags: 17 | # 18 | # * `action` 19 | # The controller action, e.g. `index`. 20 | # 21 | # * `controller` 22 | # The controller class name, e.g. `API::UsersController`. 23 | # 24 | # * `instance` 25 | # A combination of the controller class and the action, e.g. 26 | # `API::UsersController#index`. 27 | # 28 | # * `method` 29 | # The request method, e.g. `GET`. 30 | # 31 | # Additional collected values are: 32 | # 33 | # * `db_ms` 34 | # Time spend with database operations in milliseconds. 35 | # 36 | # * `view_ms` 37 | # Time spend with rendering views in milliseconds. 38 | # 39 | # * `action_ms` 40 | # Total time spend in a Rails action in milliseconds. 41 | # 42 | # These additional tags and values are collection from the 43 | # `process_action.action_controller` events usings Rails instrumentation. 44 | # 45 | class Railtie < ::Rails::Railtie 46 | config.telegraf = ::ActiveSupport::OrderedOptions.new 47 | 48 | # Connect URI or tuple 49 | config.telegraf.connect = ::Telegraf::Agent::DEFAULT_CONNECTION 50 | config.telegraf.tags = {} 51 | config.telegraf.before_send = nil 52 | 53 | # Install Rack middlewares 54 | config.telegraf.rack = ::ActiveSupport::OrderedOptions.new 55 | config.telegraf.rack.enabled = true 56 | config.telegraf.rack.series = 'requests' 57 | config.telegraf.rack.tags = {} 58 | config.telegraf.rack.before_send = nil 59 | 60 | # Install request instrumentation 61 | config.telegraf.instrumentation = true 62 | 63 | # Install Grape instrumentation 64 | config.telegraf.grape = ::ActiveSupport::OrderedOptions.new 65 | config.telegraf.grape.enabled = defined?(::Grape) 66 | 67 | # Install ActiveJob instrumentation 68 | config.telegraf.active_job = ::ActiveSupport::OrderedOptions.new 69 | config.telegraf.active_job.enabled = defined?(::ActiveJob) 70 | config.telegraf.active_job.series = 'active_job' 71 | config.telegraf.active_job.tags = {} 72 | config.telegraf.active_job.before_send = nil 73 | 74 | # Install Sidekiq middleware 75 | config.telegraf.sidekiq = ::ActiveSupport::OrderedOptions.new 76 | config.telegraf.sidekiq.enabled = defined?(::Sidekiq) 77 | config.telegraf.sidekiq.series = 'sidekiq' 78 | config.telegraf.sidekiq.tags = {} 79 | config.telegraf.sidekiq.before_send = nil 80 | 81 | initializer 'telegraf.agent' do |app| 82 | app.config.telegraf.agent ||= 83 | ::Telegraf::Agent.new( 84 | app.config.telegraf.connect, 85 | before_send: app.config.telegraf.before_send, 86 | logger: Rails.logger, 87 | tags: app.config.telegraf.tags, 88 | ) 89 | end 90 | 91 | initializer 'telegraf.rack' do |app| 92 | next unless app.config.telegraf.rack.enabled 93 | 94 | app.config.middleware.insert( 95 | 0, 96 | Telegraf::Rack, 97 | agent: app.config.telegraf.agent, 98 | before_send: app.config.telegraf.rack.before_send, 99 | logger: Rails.logger, 100 | series: app.config.telegraf.rack.series, 101 | tags: app.config.telegraf.rack.tags, 102 | ) 103 | end 104 | 105 | initializer 'telegraf.instrumentation' do |app| 106 | next unless app.config.telegraf.instrumentation 107 | 108 | ActiveSupport::Notifications.subscribe( 109 | 'process_action.action_controller', 110 | ) do |_name, start, finish, _id, payload| 111 | point = payload[:headers].env[::Telegraf::Rack::FIELD_NAME] 112 | next unless point 113 | 114 | point.tags[:action] = payload[:action] 115 | point.tags[:controller] = payload[:controller] 116 | point.tags[:instance] = "#{payload[:controller]}##{payload[:action]}" 117 | point.tags[:method] = payload[:method] 118 | 119 | point.values[:db_ms] = payload[:db_runtime].to_f 120 | point.values[:view_ms] = payload[:view_runtime].to_f 121 | point.values[:action_ms] = ((finish - start) * 1000.0) # milliseconds 122 | end 123 | end 124 | 125 | initializer 'telegraf.grape' do |app| 126 | next unless app.config.telegraf.grape.enabled 127 | 128 | ActiveSupport::Notifications.subscribe( 129 | 'endpoint_run.grape', 130 | Telegraf::Grape.new, 131 | ) 132 | end 133 | 134 | initializer 'telegraf.active_job' do |app| 135 | next unless app.config.telegraf.active_job.enabled 136 | 137 | ActiveSupport::Notifications.subscribe( 138 | 'perform.active_job', 139 | Telegraf::ActiveJob.new( 140 | agent: app.config.telegraf.agent, 141 | before_send: app.config.telegraf.active_job.before_send, 142 | series: app.config.telegraf.active_job.series, 143 | tags: app.config.telegraf.active_job.tags, 144 | ), 145 | ) 146 | end 147 | 148 | initializer 'telegraf.sidekiq' do |app| 149 | next unless app.config.telegraf.sidekiq.enabled 150 | 151 | ::Sidekiq.configure_server do |config| 152 | config.server_middleware do |chain| 153 | chain.add Telegraf::Sidekiq::Middleware, 154 | app.config.telegraf.agent, 155 | { 156 | before_send: app.config.telegraf.sidekiq.before_send, 157 | series: app.config.telegraf.sidekiq.series, 158 | tags: app.config.telegraf.sidekiq.tags, 159 | } 160 | end 161 | end 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/telegraf/serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Telegraf 4 | class Serializer 5 | QUOTE_SERIES = /[ ,]/.freeze 6 | QUOTE_TAG_KEY = /[ ,=]/.freeze 7 | QUOTE_TAG_VALUE = /[ ,=]/.freeze 8 | QUOTE_FIELD_KEY = /[ ,="]/.freeze 9 | QUOTE_FIELD_VALUE = /[\\"]/.freeze 10 | QUOTE_REPLACE = /[\t\r\n]+/.freeze 11 | 12 | def dump_all(points) 13 | points 14 | .each 15 | .filter_map {|point| dump(point) } 16 | .join("\n") 17 | end 18 | 19 | def dump(point) 20 | series = quote(point[:series], QUOTE_SERIES) 21 | return '' if series.empty? 22 | 23 | values = point.fetch(:values).filter_map do |key, value| 24 | k = quote(key.to_s, QUOTE_FIELD_KEY) 25 | v = encode_value(value) 26 | next if k.empty? || v.nil? 27 | 28 | "#{k}=#{v}" 29 | end 30 | return '' if values.empty? 31 | 32 | tags = point[:tags]&.filter_map do |key, value| 33 | k = quote(key.to_s, QUOTE_TAG_KEY) 34 | v = quote(value.to_s, QUOTE_TAG_VALUE) 35 | next if k.empty? || v.empty? 36 | 37 | "#{k}=#{v}" 38 | end 39 | 40 | StringIO.new.tap do |io| 41 | io << series 42 | io << ',' << tags.sort.join(',') if !tags.nil? && tags.any? 43 | io << ' ' << values.sort.join(',') 44 | end.string 45 | end 46 | 47 | private 48 | 49 | def encode_value(val) 50 | if val.nil? 51 | nil 52 | elsif val.is_a?(Integer) 53 | "#{val}i" 54 | elsif val.is_a?(Numeric) 55 | if val.nan? || val.infinite? 56 | nil 57 | else 58 | val.to_s 59 | end 60 | else 61 | "\"#{quote(val.to_s, QUOTE_FIELD_VALUE)}\"" 62 | end 63 | end 64 | 65 | def quote(str, rule) 66 | str 67 | .encode('UTF-8', 'UTF-8', invalid: :replace, undef: :replace, replace: '') 68 | .gsub(QUOTE_REPLACE, ' ') 69 | .gsub(rule) {|c| "\\#{c}" } 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/telegraf/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | module Telegraf 6 | module Sidekiq 7 | # Telegraf::Sidekiq::Middleware 8 | # 9 | # This Sidekiq middleware collects queue metrics and sends them to telegraf. 10 | # 11 | # 12 | # Tags: 13 | # 14 | # * `type`: 15 | # One of "job" or "scheduled_job". 16 | # 17 | # * `queue`: 18 | # The queue this job landed on. 19 | # 20 | # * `worker`: 21 | # The name of the worker class that was executed. 22 | # 23 | # * `errors`: 24 | # Whether or not this job errored. 25 | # 26 | # * `retry`: 27 | # Whether or not this execution was a retry of a previously failed one. 28 | # 29 | # 30 | # Values: 31 | # 32 | # * `app_ms`: 33 | # Total worker processing time. 34 | # 35 | # * `queue_ms`: 36 | # How long did this job wait in the queue before being processed? 37 | # Only present for "normal" (async) jobs (with tag `type` of "job"). 38 | # 39 | class Middleware 40 | include ::Telegraf::Plugin 41 | 42 | def initialize(agent, options = {}) 43 | options[:series] ||= 'sidekiq' 44 | 45 | super( 46 | **options, 47 | agent: agent 48 | ) 49 | end 50 | 51 | def call(worker, job, queue) 52 | job_start = ::Time.now.utc 53 | 54 | point = Point.new( 55 | tags: { 56 | **@tags, 57 | type: 'job', 58 | errors: true, 59 | retry: job.key?('retried_at'), 60 | queue: queue, 61 | worker: worker.class.name, 62 | }, 63 | values: { 64 | retry_count: job['retry_count'], 65 | }.compact, 66 | ) 67 | 68 | # The "enqueued_at" key is not present for scheduled jobs. 69 | # See https://github.com/mperham/sidekiq/wiki/Job-Format. 70 | if job.key?('enqueued_at') 71 | enqueued_at = Time.at( 72 | if defined?(::Sidekiq::MAJOR) && ::Sidekiq::MAJOR >= 8 73 | job['enqueued_at'].to_f / 1000 74 | else 75 | job['enqueued_at'].to_f 76 | end, 77 | ).utc 78 | 79 | point.values[:queue_ms] = (job_start - enqueued_at) * 1000 # milliseconds 80 | end 81 | 82 | # The "at" key is only present for scheduled jobs. 83 | point.tags[:type] = 'scheduled_job' if job.key?('at') 84 | 85 | begin 86 | yield 87 | 88 | # If we get here, this was a successful execution 89 | point.tags[:errors] = false 90 | ensure 91 | job_stop = ::Time.now.utc 92 | 93 | point.values[:app_ms] = (job_stop - job_start) * 1000 # milliseconds 94 | 95 | _write(point, before_send_kwargs: {worker: worker, job: job, queue: queue}) 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/telegraf/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Telegraf 4 | module VERSION 5 | MAJOR = 3 6 | MINOR = 2 7 | PATCH = 0 8 | STAGE = nil 9 | STRING = [MAJOR, MINOR, PATCH, STAGE].compact.join('.').freeze 10 | 11 | def self.to_s 12 | STRING 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "local>jgraichen/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'telegraf' 5 | 6 | require 'socket' 7 | require 'tmpdir' 8 | 9 | module Support 10 | module Tmpdir 11 | extend RSpec::SharedContext 12 | 13 | attr_reader :tmpdir 14 | 15 | around do |example| 16 | Dir.mktmpdir do |dir| 17 | @tmpdir = dir 18 | example.run 19 | end 20 | end 21 | end 22 | 23 | module Socket 24 | extend RSpec::SharedContext 25 | 26 | Point = Struct.new(:series, :tags, :values, :timestamp) # rubocop:disable Lint/StructNewOverride 27 | 28 | let(:socket) { nil } 29 | let(:last_points) { socket_parse } 30 | let(:last_point) { last_points.first } 31 | 32 | around do |example| 33 | socket 34 | example.run 35 | ensure 36 | socket&.close 37 | end 38 | 39 | def socket_read 40 | if socket.respond_to?(:accept_nonblock) 41 | begin 42 | socket.accept_nonblock.read_nonblock(4096) 43 | rescue Errno::ENOTSUP # unixgram 44 | socket.read_nonblock(4096) 45 | end 46 | else 47 | socket.read_nonblock(4096) 48 | end 49 | end 50 | 51 | def socket_parse 52 | [].tap do |data| 53 | loop do 54 | socket_read.lines.each {|line| data << _parse(line) } 55 | rescue IO::EAGAINWaitReadable 56 | break 57 | end 58 | end 59 | end 60 | 61 | REGEXP = /^(?\w+)(?:,(?.*))? (?.*)(? \d+)?$/.freeze 62 | 63 | private 64 | 65 | def _parse(line) 66 | if (m = REGEXP.match(line)) 67 | return Point.new( 68 | m['series'], 69 | _parse_fields(m['tags']), 70 | _parse_fields(m['values']), 71 | m['ts']&.strip&.to_i, 72 | ) 73 | end 74 | 75 | raise "Cannot parse: #{line}" 76 | end 77 | 78 | def _parse_fields(str) 79 | return {} unless str 80 | 81 | str.split(',').to_h do |s| 82 | s.split('=') 83 | end 84 | end 85 | end 86 | end 87 | 88 | RSpec.configure do |config| 89 | # Enable flags like --only-failures and --next-failure 90 | config.example_status_persistence_file_path = '.rspec_status' 91 | 92 | # Disable RSpec exposing methods globally on `Module` and `main` 93 | config.disable_monkey_patching! 94 | 95 | config.expect_with :rspec do |c| 96 | c.syntax = :expect 97 | end 98 | 99 | config.include Support::Tmpdir 100 | config.include Support::Socket 101 | 102 | # Use the GitHub Annotations formatter for CI 103 | if ENV['GITHUB_ACTIONS'] == 'true' 104 | require 'rspec/github' 105 | config.add_formatter RSpec::Github::Formatter 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/telegraf/active_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'telegraf/active_job' 6 | require 'tmpdir' 7 | 8 | require 'logger' 9 | require 'active_job' 10 | 11 | class HardJob < ActiveJob::Base 12 | def perform(_message) 13 | # noop 14 | end 15 | end 16 | 17 | class FailJob < ActiveJob::Base 18 | def perform(message) 19 | raise message 20 | end 21 | end 22 | 23 | RSpec.describe Telegraf::ActiveJob do 24 | subject(:work!) { HardJob.perform_now 'test' } 25 | 26 | let(:socket) { UNIXServer.new "#{tmpdir}/sock" } 27 | let(:agent) { Telegraf::Agent.new "unix:#{tmpdir}/sock" } 28 | let(:args) { {} } 29 | 30 | before do 31 | ActiveSupport::Notifications.subscribe( 32 | 'perform.active_job', 33 | Telegraf::ActiveJob.new(agent: agent, **args), 34 | ) 35 | 36 | ActiveJob::Base.logger = Logger.new(IO::NULL) 37 | end 38 | 39 | context 'successful async execution' do 40 | it 'job=HardJob,queue=default,errors=false app_ms' do 41 | work! 42 | 43 | expect(last_point.series).to eq 'active_job' 44 | expect(last_point.tags).to include 'job' => 'HardJob', 'queue' => 'default', 'errors' => 'false' 45 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/ 46 | end 47 | end 48 | 49 | context 'with error' do 50 | subject(:work!) { FailJob.perform_now 'test' } 51 | 52 | it 'job=HardJob,queue=default,errors=true app_ms' do 53 | work! rescue nil 54 | 55 | expect(last_point.series).to eq 'active_job' 56 | expect(last_point.tags).to include 'job' => 'FailJob', 'queue' => 'default', 'errors' => 'true' 57 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/ 58 | end 59 | end 60 | 61 | context 'with custom series name' do 62 | let(:args) { {series: 'background'} } 63 | 64 | it 'uses the custom series' do 65 | work! 66 | 67 | expect(last_point.series).to eq 'background' 68 | expect(last_point.tags).to include 'job' => 'HardJob', 'queue' => 'default', 'errors' => 'false' 69 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/ 70 | end 71 | end 72 | 73 | context 'with extra tags' do 74 | let(:args) { {tags: {my: 'tag'}} } 75 | 76 | it 'adds the tag to the standard tags' do 77 | work! 78 | 79 | expect(last_point.series).to eq 'active_job' 80 | expect(last_point.tags).to include 'job' => 'HardJob', 'queue' => 'default', 'errors' => 'false', 'my' => 'tag' 81 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/ 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/telegraf/agent_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'telegraf' 6 | 7 | RSpec.describe Telegraf::Agent do 8 | subject(:agent) { Telegraf::Agent.new("unix:#{tmpdir}/sock", **kwargs) } 9 | 10 | let(:kwargs) { {} } 11 | let(:socket) { UNIXServer.new "#{tmpdir}/sock" } 12 | 13 | describe '#write' do 14 | context 'with tags' do 15 | let(:kwargs) { {tags: {app: 'test', tagged: 'yes'}} } 16 | 17 | it 'merges tags into event' do 18 | # Write app tag here too to ensure it is overwritten by global tags 19 | agent.write!('series', tags: {app: 'no', field: 'yes'}, values: {a: 1}) 20 | expect(socket_read).to eq 'series,app=test,field=yes,tagged=yes a=1i' 21 | end 22 | end 23 | end 24 | 25 | describe '@before_send' do 26 | let(:kwargs) { {before_send: before_send} } 27 | let(:before_send) do 28 | lambda {|data| 29 | data.delete_if {|line| line[:tags].key?(:drop) } 30 | data 31 | } 32 | end 33 | 34 | it 'drops all points when all points match' do 35 | agent.write!('series', tags: {drop: 1}, values: {a: 1}) 36 | expect { socket_read }.to raise_error(EOFError) 37 | end 38 | 39 | it 'drops matching points' do 40 | agent.write!('series', tags: {drop: 1}, values: {a: 1}) 41 | agent.write!('series', tags: {field: 'yes'}, values: {a: 1}) 42 | 43 | expect { socket_read }.to raise_error(EOFError) 44 | expect(socket_read).to eq 'series,field=yes a=1i' 45 | end 46 | 47 | it 'drops matching points in bulk mode' do 48 | agent.write!([ 49 | {series: 'series', tags: {drop: 1}, values: {a: 1}}, 50 | {series: 'series', tags: {field: 'yes'}, values: {a: 1}}, 51 | ]) 52 | 53 | expect(socket_read).to eq 'series,field=yes a=1i' 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/telegraf/rack_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'rack/mock' 6 | require 'telegraf/rack' 7 | require 'tmpdir' 8 | 9 | require 'logger' 10 | 11 | RSpec.describe Telegraf::Rack do 12 | subject(:mock) do 13 | Rack::MockRequest.new(described_class.new(app, agent: agent, **args)) 14 | end 15 | 16 | let(:socket) { UNIXServer.new "#{tmpdir}/sock" } 17 | let(:agent) { Telegraf::Agent.new "unix:#{tmpdir}/sock" } 18 | let(:args) { {} } 19 | 20 | let(:app) { ->(_env) { [200, {}, []] } } 21 | 22 | context 'with successful request' do 23 | it 'status=200 app_ms,send_ms,request_ms' do 24 | mock.request 25 | expect(socket_read).to match(/\Arack,status=200 app_ms=\d+\.\d+,request_ms=\d+\.\d+,send_ms=\d+\.\d+\z/) 26 | end 27 | end 28 | 29 | context 'with a 404 response' do 30 | let(:app) { ->(_env) { [404, {}, []] } } 31 | 32 | it 'includes status=404' do 33 | mock.request 34 | expect(last_points.size).to eq 1 35 | expect(last_point.tags).to eq 'status' => '404' 36 | end 37 | end 38 | 39 | context 'with error' do 40 | let(:app) { ->(_env) { raise 'fail' } } 41 | 42 | it 'status=-1 app_ms,request_ms' do 43 | mock.request rescue nil 44 | expect(last_points.size).to eq 1 45 | expect(last_point.tags).to eq 'status' => '-1' 46 | expect(last_point.values.keys).to match_array %w[app_ms request_ms] 47 | end 48 | end 49 | 50 | context 'with X-Request-Start' do 51 | it 'includes queue_ms value' do 52 | mock.request('GET', '/', {'HTTP_X_REQUEST_START' => "t=#{Time.now.utc.to_f}"}) 53 | 54 | expect(last_points.size).to eq 1 55 | expect(last_point.values.keys).to include 'queue_ms' 56 | expect(last_point.values['queue_ms']).to match(/\A\d+\.\d+\z/) 57 | end 58 | end 59 | 60 | context 'with extra tags and values' do 61 | let(:app) do 62 | lambda do |env| 63 | env['telegraf.rack.point'].tags[:my] = 'tag' 64 | env[Telegraf::Rack::FIELD_NAME].values[:val] = 100 65 | 66 | [200, {}, []] 67 | end 68 | end 69 | 70 | it 'status=200 app_ms,send_ms,request_ms' do 71 | mock.request 72 | 73 | expect(last_points.size).to eq 1 74 | expect(last_point.tags).to include 'my' => 'tag' 75 | expect(last_point.values).to include 'val' => '100i' 76 | end 77 | end 78 | 79 | context 'with write error' do 80 | before { allow(agent).to receive(:write).and_raise('write failed') } 81 | 82 | let(:io) { StringIO.new } 83 | 84 | it 'logs error to rack logger' do 85 | mock.request('GET', '/', {'rack.logger' => Logger.new(io)}) 86 | 87 | expect(io.string).to match(/ERROR .* write failed \(RuntimeError\)/) 88 | end 89 | 90 | context 'with explicitly given logger' do 91 | let(:args) { {logger: Logger.new(io)} } 92 | 93 | it 'logs error to given logger' do 94 | rackio = StringIO.new 95 | mock.request('GET', '/', {'rack.logger' => Logger.new(rackio)}) 96 | 97 | expect(io.string).to match(/ERROR .* write failed \(RuntimeError\)/) 98 | expect(rackio.string).to be_empty 99 | end 100 | end 101 | 102 | context 'without logger' do 103 | it 'nothing happens' do 104 | expect do 105 | mock.request('GET', '/', {'rack.logger' => nil}) 106 | end.not_to raise_error 107 | end 108 | end 109 | end 110 | 111 | context 'with before_send filter' do 112 | let(:args) { {before_send: before_send} } 113 | 114 | context 'dropping rack paths' do 115 | let(:before_send) do 116 | lambda {|point, request:, **| 117 | return if %r{^/healthcheck}.match?(request.path) 118 | 119 | point.values[:path] = request.path 120 | point 121 | } 122 | end 123 | 124 | it 'drops matching points' do 125 | mock.request('GET', '/', {}) 126 | mock.request('GET', '/healthcheck', {}) 127 | 128 | expect(last_points.size).to eq 1 129 | expect(last_point.values).to include('path' => '"/"') 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/telegraf/railtie_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'telegraf/rails' 5 | 6 | require 'action_controller' 7 | 8 | ENV['RAILS_ENV'] ||= 'production' 9 | 10 | class TestController < ActionController::Base 11 | def index 12 | render plain: 'test' 13 | end 14 | end 15 | 16 | RSpec.describe Telegraf::Railtie do 17 | subject(:app) do 18 | Class.new(Rails::Application) do 19 | config.eager_load = true 20 | config.secret_key_base = 'secret' 21 | config.action_dispatch.show_exceptions = false 22 | 23 | routes.append do 24 | get '/' => 'test#index' 25 | end 26 | end 27 | end 28 | 29 | around do |example| 30 | defaults = config.telegraf.deep_dup 31 | begin 32 | example.run 33 | ensure 34 | config.telegraf = defaults 35 | end 36 | end 37 | 38 | let(:config) { app.config } 39 | let(:application) do 40 | # Rails removes the support of multiple instances, which includes freezing some setting values. 41 | # This is the workaround to avoid FrozenError. Related issue: https://github.com/rails/rails/issues/42319 42 | ActiveSupport::Dependencies.autoload_once_paths = [] 43 | ActiveSupport::Dependencies.autoload_paths = [] 44 | 45 | app.tap(&:initialize!) 46 | end 47 | 48 | describe '' do 49 | describe 'telegraf.connect' do 50 | it 'defaults to ::Telegraf::Agent::DEFAULT_CONNECTION' do 51 | expect(config.telegraf.connect).to eq Telegraf::Agent::DEFAULT_CONNECTION 52 | end 53 | end 54 | 55 | describe 'telegraf.tags' do 56 | subject { config.telegraf.tags } 57 | 58 | it { is_expected.to eq({}) } 59 | end 60 | 61 | describe 'telegraf.rack.enabled' do 62 | subject { config.telegraf.rack.enabled } 63 | 64 | it { is_expected.to be true } 65 | end 66 | 67 | describe 'telegraf.rack.series' do 68 | subject { config.telegraf.rack.series } 69 | 70 | it { is_expected.to eq 'requests' } 71 | end 72 | 73 | describe 'telegraf.rack.tags' do 74 | subject { config.telegraf.rack.tags } 75 | 76 | it { is_expected.to eq({}) } 77 | end 78 | end 79 | 80 | describe '' do 81 | it 'creates a telegraf agent' do 82 | config.telegraf.connect = 'tcp://localhost:1234' 83 | config.telegraf.tags = {app: 'name'} 84 | 85 | application.config.telegraf.agent.tap do |agent| 86 | expect(agent).to be_a Telegraf::Agent 87 | expect(agent.uri).to eq URI.parse('tcp://localhost:1234') 88 | expect(agent.tags).to eq({app: 'name'}) 89 | end 90 | end 91 | 92 | it 'installs middleware in first place' do 93 | middleware = application.config.middleware.first 94 | expect(middleware.klass).to eq Telegraf::Rack 95 | 96 | kwargs = middleware.args.first 97 | expect(kwargs[:agent]).to eq application.config.telegraf.agent 98 | expect(kwargs[:series]).to eq 'requests' 99 | expect(kwargs[:tags]).to eq({}) 100 | end 101 | 102 | context 'with rack disabled' do 103 | before { config.telegraf.rack.enabled = false } 104 | 105 | it 'does not install middleware' do 106 | expect(application.config.middleware.to_a).not_to include Telegraf::Rack 107 | end 108 | end 109 | end 110 | 111 | describe '' do 112 | let(:mock) { Rack::MockRequest.new(application) } 113 | let(:socket) { UDPSocket.new.tap {|s| s.bind('localhost', 8094) } } 114 | 115 | it 'assigns extra tags and values' do 116 | expect(application.config.middleware).to include Telegraf::Rack 117 | 118 | response = mock.request 119 | expect(response.status).to eq 200 120 | expect(response.body).to eq 'test' 121 | 122 | parsed = socket_parse 123 | expect(parsed.size).to eq 1 124 | expect(parsed[0].series).to eq 'requests' 125 | expect(parsed[0].tags).to eq({ 126 | 'action' => 'index', 127 | 'controller' => 'TestController', 128 | 'instance' => 'TestController#index', 129 | 'method' => 'GET', 130 | 'status' => '200', 131 | }) 132 | expect(parsed[0].values.keys).to match_array %w[action_ms app_ms db_ms request_ms send_ms view_ms] 133 | end 134 | 135 | context 'with global tags' do 136 | before { config.telegraf.rack.tags = {my: 'tag'} } 137 | 138 | it 'include global tags' do 139 | mock.request 140 | expect(socket_parse[0].tags).to include 'my' => 'tag' 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/telegraf/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'telegraf' 6 | 7 | RSpec.describe Telegraf::Serializer do 8 | subject(:serializer) { Telegraf::Serializer.new } 9 | 10 | describe '#dump' do 11 | it 'escapes whitespace in series' do 12 | expect( 13 | serializer.dump({ 14 | series: 'my series', 15 | values: {a: 1}, 16 | }), 17 | ).to eq 'my\ series a=1i' 18 | end 19 | 20 | it 'replaces LF with space' do 21 | expect( 22 | serializer.dump({ 23 | series: 'series', 24 | tags: {app: "no\nyes"}, 25 | values: {a: 1}, 26 | }), 27 | ).to eq 'series,app=no\\ yes a=1i' 28 | end 29 | 30 | it 'replaces TAB with space' do 31 | expect( 32 | serializer.dump({ 33 | series: 'series', 34 | tags: {app: "no\tyes"}, 35 | values: {a: 1}, 36 | }), 37 | ).to eq 'series,app=no\\ yes a=1i' 38 | end 39 | 40 | it 'replaces CR with space' do 41 | expect( 42 | serializer.dump({ 43 | series: 'series', 44 | tags: {app: "no\ryes"}, 45 | values: {a: 1}, 46 | }), 47 | ).to eq 'series,app=no\\ yes a=1i' 48 | end 49 | 50 | it 'replaces CRLF with space' do 51 | expect( 52 | serializer.dump({ 53 | series: 'series', 54 | tags: {app: "no\r\nyes"}, 55 | values: {a: 1}, 56 | }), 57 | ).to eq 'series,app=no\\ yes a=1i' 58 | end 59 | 60 | it 'escapes quotes in field values' do 61 | expect( 62 | serializer.dump({ 63 | series: 'series', 64 | values: {a: 'string "data"'}, 65 | }), 66 | ).to eq 'series a="string \"data\""' 67 | end 68 | 69 | it 'strips invalid UTF-8 from series' do 70 | expect( 71 | serializer.dump({ 72 | series: "series\xC9", 73 | values: {a: 1}, 74 | }), 75 | ).to eq 'series a=1i' 76 | end 77 | 78 | it 'skips series with only invalid UTF-8' do 79 | expect( 80 | serializer.dump({ 81 | series: "\xC9", 82 | values: {a: 1}, 83 | }), 84 | ).to eq '' 85 | end 86 | 87 | it 'ignores nil value' do 88 | expect( 89 | serializer.dump({ 90 | series: 'series', 91 | values: {a: 1, b: nil}, 92 | }), 93 | ).to eq 'series a=1i' 94 | end 95 | 96 | it 'ignores nil values' do 97 | expect( 98 | serializer.dump({ 99 | series: 'series', 100 | values: {a: nil}, 101 | }), 102 | ).to eq '' 103 | end 104 | 105 | it 'ignores value name with invalid encoding' do 106 | expect( 107 | serializer.dump({ 108 | series: 'series', 109 | values: {a: 1, '\xC9': nil}, 110 | }), 111 | ).to eq 'series a=1i' 112 | end 113 | 114 | it 'ignores value names with invalid encoding' do 115 | expect( 116 | serializer.dump({ 117 | series: 'series', 118 | values: {'\xC9': nil}, 119 | }), 120 | ).to eq '' 121 | end 122 | 123 | it 'ignores nil tag' do 124 | expect( 125 | serializer.dump({ 126 | series: 'series', 127 | tags: {a: 'test', b: nil}, 128 | values: {a: 1}, 129 | }), 130 | ).to eq 'series,a=test a=1i' 131 | end 132 | 133 | it 'ignores nil tags' do 134 | expect( 135 | serializer.dump({ 136 | series: 'series', 137 | tags: {a: nil}, 138 | values: {a: 1}, 139 | }), 140 | ).to eq 'series a=1i' 141 | end 142 | 143 | it 'ignores NaN values' do 144 | expect( 145 | serializer.dump({ 146 | series: 'series', 147 | values: {a: 1, b: 0 / 0.0}, 148 | }), 149 | ).to eq 'series a=1i' 150 | end 151 | 152 | it 'ignores Infinity values' do 153 | expect( 154 | serializer.dump({ 155 | series: 'series', 156 | values: {a: 1, b: 1 / 0.0}, 157 | }), 158 | ).to eq 'series a=1i' 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /spec/telegraf/sidekiq_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'telegraf/sidekiq' 6 | require 'tmpdir' 7 | 8 | require 'sidekiq' 9 | require 'sidekiq/testing' 10 | 11 | class HardWorker 12 | include Sidekiq::Worker 13 | 14 | def perform(_message) 15 | # noop 16 | end 17 | end 18 | 19 | class FailWorker 20 | include Sidekiq::Worker 21 | 22 | def perform(message) 23 | raise message 24 | end 25 | end 26 | 27 | RSpec.describe Telegraf::Sidekiq::Middleware do 28 | subject(:work!) do 29 | queue! 30 | Sidekiq::Worker.drain_all 31 | end 32 | 33 | let(:queue!) { HardWorker.perform_async('test') } 34 | let(:socket) { UNIXServer.new "#{tmpdir}/sock" } 35 | let(:agent) { Telegraf::Agent.new "unix:#{tmpdir}/sock" } 36 | let(:args) { {} } 37 | 38 | before do 39 | Sidekiq::Testing.server_middleware do |chain| 40 | chain.add Telegraf::Sidekiq::Middleware, agent, args 41 | end 42 | 43 | Sidekiq::Worker.clear_all 44 | end 45 | 46 | context 'successful async execution' do 47 | it 'type=job,errors=false app_ms,queue_ms' do 48 | work! 49 | 50 | expect(last_point.series).to eq 'sidekiq' 51 | expect(last_point.tags).to include( 52 | 'type' => 'job', 53 | 'worker' => 'HardWorker', 54 | 'queue' => 'default', 55 | 'errors' => 'false', 56 | 'retry' => 'false', 57 | ) 58 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/, 'queue_ms' => /^\d+\.\d+$/ 59 | end 60 | end 61 | 62 | context 'successful scheduled execution' do 63 | let(:queue!) { HardWorker.perform_at(Time.now.utc + 20, 'test') } 64 | 65 | it 'type=scheduled_job,errors=false app_ms' do 66 | work! 67 | 68 | expect(last_point.series).to eq 'sidekiq' 69 | expect(last_point.tags).to include( 70 | 'type' => 'scheduled_job', 71 | 'worker' => 'HardWorker', 72 | 'queue' => 'default', 73 | 'errors' => 'false', 74 | 'retry' => 'false', 75 | ) 76 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/ 77 | end 78 | end 79 | 80 | context 'with error' do 81 | let(:queue!) { FailWorker.perform_async('test') } 82 | 83 | it 'type=job,errors=true app_ms,queue_ms' do 84 | begin 85 | work! 86 | rescue StandardError 87 | nil 88 | end 89 | 90 | expect(last_point.series).to eq 'sidekiq' 91 | expect(last_point.tags).to include( 92 | 'type' => 'job', 93 | 'worker' => 'FailWorker', 94 | 'queue' => 'default', 95 | 'errors' => 'true', 96 | 'retry' => 'false', 97 | ) 98 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/, 'queue_ms' => /^\d+\.\d+$/ 99 | end 100 | end 101 | 102 | context 'with custom series name' do 103 | let(:args) { {series: 'background'} } 104 | 105 | it 'uses the custom series' do 106 | work! 107 | 108 | expect(last_point.series).to eq 'background' 109 | expect(last_point.tags).to include( 110 | 'type' => 'job', 111 | 'worker' => 'HardWorker', 112 | 'queue' => 'default', 113 | 'errors' => 'false', 114 | 'retry' => 'false', 115 | ) 116 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/, 'queue_ms' => /^\d+\.\d+$/ 117 | end 118 | end 119 | 120 | context 'with extra tags' do 121 | let(:args) { {tags: {my: 'tag'}} } 122 | 123 | it 'adds the tag to the standard tags' do 124 | work! 125 | 126 | expect(last_point.series).to eq 'sidekiq' 127 | expect(last_point.tags).to include( 128 | 'type' => 'job', 129 | 'worker' => 'HardWorker', 130 | 'queue' => 'default', 131 | 'errors' => 'false', 132 | 'retry' => 'false', 133 | 'my' => 'tag', 134 | ) 135 | expect(last_point.values).to match 'app_ms' => /^\d+\.\d+$/, 'queue_ms' => /^\d+\.\d+$/ 136 | end 137 | end 138 | 139 | context 'with before_send filter' do 140 | let(:args) { {before_send: before_send} } 141 | 142 | context 'excluding specific worker' do 143 | let(:before_send) do 144 | lambda {|point, worker:, **| 145 | return if worker.instance_of?(HardWorker) 146 | 147 | point 148 | } 149 | end 150 | 151 | it 'drops matching points' do 152 | work! 153 | expect(last_points.size).to eq 0 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/telegraf_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | require 'tmpdir' 6 | 7 | RSpec.describe Telegraf do 8 | it 'has a version number' do 9 | expect(Telegraf::VERSION).not_to be_nil 10 | end 11 | 12 | context 'with UNIX socket' do 13 | let(:agent) { Telegraf::Agent.new "unix:#{tmpdir}/sock" } 14 | let(:socket) { UNIXServer.new "#{tmpdir}/sock" } 15 | 16 | it 'writes multiple points' do 17 | agent.write( 18 | [ 19 | {series: 'demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}}, 20 | {series: 'demo', tags: {a: '1', b: 2}, values: {a: 6, b: 2.5}}, 21 | ], 22 | ) 23 | 24 | expect(socket_read).to eq "demo,a=1,b=2 a=1i,b=2.1\ndemo,a=1,b=2 a=6i,b=2.5" 25 | end 26 | 27 | it 'writes single points' do 28 | agent.write('demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}) 29 | expect(socket_read).to eq 'demo,a=1,b=2 a=1i,b=2.1' 30 | end 31 | end 32 | 33 | context 'with UNIXGRAM socket' do 34 | let(:agent) { Telegraf::Agent.new "unixgram:#{tmpdir}/sock" } 35 | let(:socket) do 36 | Socket.new(:UNIX, :DGRAM).tap do |socket| 37 | socket.bind Socket.pack_sockaddr_un "#{tmpdir}/sock" 38 | end 39 | end 40 | 41 | it 'write points' do 42 | agent.write('demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}) 43 | expect(socket_read).to eq 'demo,a=1,b=2 a=1i,b=2.1' 44 | end 45 | end 46 | 47 | context 'with TCP socket' do 48 | let(:agent) { Telegraf::Agent.new 'tcp://localhost:8094' } 49 | let(:socket) { TCPServer.new 'localhost', 8094 } 50 | 51 | it 'write points' do 52 | agent.write('demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}) 53 | expect(socket_read).to eq 'demo,a=1,b=2 a=1i,b=2.1' 54 | end 55 | end 56 | 57 | context 'with UDP socket' do 58 | let(:agent) { Telegraf::Agent.new 'udp://localhost:8094' } 59 | let(:socket) { UDPSocket.new.tap {|s| s.bind 'localhost', 8094 } } 60 | 61 | it 'write points' do 62 | agent.write('demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}) 63 | expect(socket_read).to eq 'demo,a=1,b=2 a=1i,b=2.1' 64 | end 65 | end 66 | 67 | context 'with default' do 68 | let(:agent) { Telegraf::Agent.new 'udp://localhost:8094' } 69 | let(:socket) { UDPSocket.new.tap {|s| s.bind 'localhost', 8094 } } 70 | 71 | it 'writes points to UDP on localhost:8094' do 72 | agent.write('demo', tags: {a: 1, b: 2}, values: {a: 1, b: 2.1}) 73 | expect(socket_read).to eq 'demo,a=1,b=2 a=1i,b=2.1' 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /telegraf.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'telegraf/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'telegraf' 9 | spec.version = Telegraf::VERSION 10 | spec.authors = ['Jan Graichen'] 11 | spec.email = ['jgraichen@altimos.de'] 12 | 13 | spec.summary = 'Metric Reporter to local telegraf agent' 14 | spec.description = 'Metric Reporter to local telegraf agent' 15 | spec.homepage = 'https://github.com/jgraichen/telegraf-ruby' 16 | spec.license = 'LGPLv3' 17 | 18 | spec.metadata = { 19 | 'rubygems_mfa_required' => 'true', 20 | } 21 | 22 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 23 | f.match(%r{^(test|spec|features)/}) 24 | end 25 | 26 | spec.bindir = 'exe' 27 | spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) } 28 | spec.require_paths = ['lib'] 29 | 30 | spec.required_ruby_version = '>= 2.7' 31 | end 32 | --------------------------------------------------------------------------------