├── .circleci └── config.yml ├── .credo.exs ├── .formatter.exs ├── .github └── dependabot.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── assets ├── m.png └── mobius-name.png ├── example ├── .formatter.exs ├── .gitignore ├── README.md ├── lib │ ├── example.ex │ └── example │ │ └── application.ex ├── mix.exs ├── mix.lock └── test │ ├── example_test.exs │ └── test_helper.exs ├── lib ├── mobius.ex └── mobius │ ├── asciichart.ex │ ├── auto_save.ex │ ├── clock.ex │ ├── event.ex │ ├── event_log.ex │ ├── events.ex │ ├── events_server.ex │ ├── exceptions.ex │ ├── exports.ex │ ├── exports │ ├── csv.ex │ ├── metrics.ex │ └── mobius_binary_format.ex │ ├── metrics_table.ex │ ├── metrics_table │ └── monitor.ex │ ├── registry.ex │ ├── report_server.ex │ ├── rrd.ex │ ├── scraper.ex │ ├── summary.ex │ └── time_server.ex ├── mix.exs ├── mix.lock └── test ├── mobius ├── asciichart.exs ├── event_log_test.exs ├── events_test.exs ├── exports │ ├── csv_test.exs │ └── mobius_binary_format_test.exs ├── exports_test.exs ├── metrics │ └── metrics_table_test.exs ├── rrd_test.exs └── summary_test.exs ├── mobius_test.exs └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | latest: &latest 4 | pattern: "^1.16.*-erlang-26.*$" 5 | 6 | tags: &tags 7 | [ 8 | 1.16.1-erlang-26.2.2-alpine-3.19.1, 9 | 1.15.7-erlang-26.1.2-alpine-3.18.4, 10 | 1.14.5-erlang-25.3.2-alpine-3.18.0, 11 | 1.13.4-erlang-25.3.2-alpine-3.18.0, 12 | 1.12.3-erlang-24.3.4.11-alpine-3.18.0, 13 | 1.11.4-erlang-23.3.4.13-alpine-3.15.3 14 | ] 15 | 16 | jobs: 17 | build-test: 18 | parameters: 19 | tag: 20 | type: string 21 | docker: 22 | - image: hexpm/elixir:<< parameters.tag >> 23 | working_directory: ~/repo 24 | environment: 25 | LC_ALL: C.UTF-8 26 | steps: 27 | - run: 28 | name: Install system dependencies 29 | command: apk add --no-cache git 30 | - checkout 31 | - run: 32 | name: Install hex and rebar 33 | command: | 34 | mix local.hex --force 35 | mix local.rebar --force 36 | - restore_cache: 37 | keys: 38 | - v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 39 | - run: mix deps.get 40 | - run: mix test 41 | - when: 42 | condition: 43 | matches: { <<: *latest, value: << parameters.tag >> } 44 | steps: 45 | - run: mix format --check-formatted 46 | - run: mix deps.unlock --check-unused 47 | - run: mix docs 48 | - run: mix hex.build 49 | - run: mix credo -a --strict 50 | - run: mix dialyzer 51 | - save_cache: 52 | key: v1-mix-cache-<< parameters.tag >>-{{ checksum "mix.lock" }} 53 | paths: 54 | - _build 55 | - deps 56 | 57 | automerge: 58 | docker: 59 | - image: alpine:3.18.4 60 | steps: 61 | - run: 62 | name: Install GitHub CLI 63 | command: apk add --no-cache build-base github-cli 64 | - run: 65 | name: Attempt PR automerge 66 | command: | 67 | author=$(gh pr view "${CIRCLE_PULL_REQUEST}" --json author --jq '.author.login' || true) 68 | 69 | if [ "$author" = "app/dependabot" ]; then 70 | gh pr merge "${CIRCLE_PULL_REQUEST}" --auto --rebase || echo "Failed trying to set automerge" 71 | else 72 | echo "Not a dependabot PR, skipping automerge" 73 | fi 74 | 75 | workflows: 76 | checks: 77 | jobs: 78 | - build-test: 79 | name: << matrix.tag >> 80 | matrix: 81 | parameters: 82 | tag: *tags 83 | 84 | - automerge: 85 | requires: *tags 86 | context: org-global 87 | filters: 88 | branches: 89 | only: /^dependabot.*/ 90 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # config/.credo.exs 2 | %{ 3 | configs: [ 4 | %{ 5 | name: "default", 6 | checks: [ 7 | {Credo.Check.Readability.LargeNumbers, only_greater_than: 86400}, 8 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true} 9 | ] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | {"mode":"full","isActive":false} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | mobius-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | > Major version zero (0.y.z) is for initial development. Anything MAY change at 8 | any time. The public API SHOULD NOT be considered stable. 9 | 10 | ## [v0.6.1] - 2024-04-02 11 | 12 | ### Changed 13 | 14 | * Allow `:telemetry_metrics` v1.0.0 and later now that it's been released 15 | * Fix `Logger.warn` deprecations 16 | * Fix binary format validation issue due to map sort order change in Erlang 26. 17 | 18 | ## [v0.6.0] - 2022-09-09 19 | 20 | The breaking change in Mobius is the removal of remote reporting and the 21 | functionality built around that such as configuring a remote reporter to send 22 | a metric report at some interval. 23 | 24 | If this functionality is something you still want, you can provide a GenServer 25 | that executes your reporting code at some interval. This will allow the maximum 26 | flexibility to how you want your software to report metrics. 27 | 28 | ### Changed 29 | 30 | * Remove `Mobius.RemoteReporter` 31 | * Remove `:remote_reporter` configuration from `Mobius.arg()` 32 | * Remove `:remote_reporter_interval` configuration from `Mobius.arg()` 33 | * Remove `Mobius.RemoteReporters.LoggerReporter` 34 | 35 | ### Added 36 | 37 | * `Mobius.Event` 38 | * `Mobius.EventLog` 39 | * `Mobius.Clock` 40 | * `Mobius.get_latest_metrics/1` 41 | * `Mobius.get_latest_events/1` 42 | * `:events` option to `Mobius.arg()` 43 | * `:event_log_size` option to `Mobius.arg()` 44 | * `:clock` option to `Mobius.arg()` 45 | * `:session` option to `Mobius.arg()` 46 | * `Mobius.session()` 47 | 48 | ## [v0.5.1] - 2022-06-01 49 | 50 | ### Added 51 | 52 | - Added the ability for a remote reporter to response with 53 | `{:error, reason, new_state}`. 54 | 55 | ## [v0.5.0] - 2022-05-31 56 | 57 | Breaking changes for three functions in the `Mobius.Exports` module: 58 | 59 | 1. `Mobius.Exports.series/4` 60 | 1. `Mobius.Exports.metrics/4` 61 | 1. `Mobius.Exports.plot/4` 62 | 63 | If you are not directly calling these functions in your code you're safe to upgrade. 64 | 65 | The first two used to return either `{:ok, results}` or `{:error, reason}` but 66 | now they will only return their result. For `Mobius.Exports.series/4` the return 67 | value is now `[integer()]` and for `Mobius.Exports.metrics/4` the return type is 68 | now `[Mobius.metric()]`. `Mobius.Exports.plot/4` still returns `:ok` on success, 69 | but can now return `{:error, UnsupportedMetricError.t()}`. 70 | 71 | ### Changed 72 | 73 | * `Mobius.Exports.series/4` return type was 74 | `{:ok, [integer()]} | {:error, UnsupportedMetricError.t()}` and now is 75 | `[integer()]`. 76 | * `Mobius.Exports.metrics/4` return type was 77 | `{:ok, [Mobius.metric()]} | {:error, UnsupportedMetricError.t()}` and now is 78 | `[Mobius.metric()]` 79 | * `Mobius.Exports.plot/4` was just `:ok` but now is 80 | `:ok | {:error, UnsupportedMetricError.t()}` 81 | 82 | ### Added 83 | 84 | * `Mobius.RemoteReporter` behaviour to allow for reporting metrics to a remote 85 | server. 86 | * Add `:remote_reporter` and `:remote_report_interval` options to the 87 | `Mobius.arg()` type. 88 | * Support for specifying which summary metric you want to export. (@ewildgoose) 89 | * Support for summary metrics types in some exports. (@ewildgoose) 90 | * Add standard deviation calculation to the summary metric type. (@ewildgoose) 91 | * New `Mobius.Exports.export_metric_type()` that allows for specifying the 92 | summary metric type. 93 | 94 | ### Misc 95 | 96 | * Update `ex_doc` to `v.0.28.4` 97 | * Update `telemetry` to `v1.1.0` 98 | * Fix up typos (@kianmeng) 99 | 100 | ## [v0.4.0] - 2022-03-25 101 | 102 | ### Changed 103 | 104 | * `Mobius.plot/3` is now `Mobius.Exports.plot/4` 105 | * `Mobius.to_csv/3` is now `Mobius.Exports.csv/4` 106 | * `Mobius.filter_metrics/3` is now `Mobius.Exports.metrics/4` 107 | * `Mobius.name()` is now `Mobius.instance()` 108 | * Mobius functions that need to know the name of the mobius instance now 109 | expect `:mobius_instance` and not `:name` 110 | * `Mobius.metric_name()` is no longer a list of `atoms()` but is now the metric 111 | name as a string 112 | * `Mobius.RRD` internal metric format 113 | * `Mobius.RRD.insert/3` typespec now expects `[Mobius.metric()]` as the last 114 | parameter 115 | 116 | ### Removed 117 | 118 | * `Mobius.filter_opt()` type 119 | * `Mobius.csv_opt()` type 120 | * `Mobius.plot_opt()` type 121 | * `Mobius.query_opts/1` function 122 | * `Mobius.to_csv/3` function 123 | * `Mobius.plot/3` function 124 | * `Mobius.filter_metrics/3` function 125 | * `Mobius.make_bundle/2` function (use `Mobius.mbf/1` instead) 126 | * `Mobius.Bundle` module 127 | * `Mobius.record()` type 128 | 129 | ### Added 130 | 131 | * `Mobius.Exports` module for APIs concerning retrieving historical data in 132 | various formats 133 | * `Mobius.Exports.csv/4` generates a CSV either as a string, to the console, or 134 | to a file 135 | * `Mobius.Exports.series/4` generates a series for historical data 136 | * `Mobius.Exports.metrics/4` retrieves the raw historical metric data 137 | * `Mobius.Exports.plot/4` generates a line plot to the console 138 | * `Mobius.Exports.mbf/1` generates a binary that contains all current metrics 139 | * `Mobius.Exports.parse_mbf/1` parses a binary that is in the Mobius Binary Format 140 | * `Mobius.Exports.UnsupportedMetricError` 141 | * `Mobius.Exports.MBFParseError` 142 | * `Mobius.FileError` 143 | * `:name` field to `Mobius.metric()` type 144 | 145 | ## [v0.3.7] - 2022-03-16 146 | 147 | This release brings in a bunch of new features and bug fixes. Along with 148 | basic maintenance like dependency updates and documentation improvements 149 | (@ewildgoose). 150 | 151 | ### Added 152 | 153 | * Create, save, and extract tar files that contain metric data, see 154 | `Mobius.Bundles` and `Mobius.make_bundle/2` for more information. 155 | * `Mobius.filter_metrics/3` to filter for desired metrics to enable the 156 | metrics to be consumed externally (@ewildgoose) 157 | * `Mobius.save/1` to manually save the state of the metric data for Mobius 158 | (@ewildgoose) 159 | * `:autosave_interval` option to Mobius to enable a saving data at the given 160 | interval (@ewildgoose) 161 | 162 | ### Fixes 163 | 164 | * Unit conversion not working correctly (@ewildgoose) 165 | * Error handling for when the `:persistence_path` is missing (@ewildgoose) 166 | * Error handling when there is no data to plot (@ewildgoose) 167 | * Crash when plotting an array of identical values (@ewildgoose) 168 | * Correct off by one error when plotting (@ewildgoose) 169 | 170 | ## [v0.3.6] - 2022-01-25 171 | 172 | ### Added 173 | 174 | * Support for `Telemetry.Metrics.Summary` metric type 175 | 176 | ## [v0.3.5] - 2021-12-2 177 | 178 | ### Fixes 179 | 180 | * Fix crash when initializing metrics table when the ETS file cannot be read (@jfcloutier) 181 | 182 | ## [v0.3.4] - 2021-11-15 183 | 184 | ### Fixes 185 | 186 | * Fix crash when a history file is unreadable during initialization (@mdwaud) 187 | 188 | ## [v0.3.3] - 2021-10-20 189 | 190 | ### Fixes 191 | 192 | * Not able to pass a path for persistence that contains non-existing sub 193 | directories. Thank you [LostKobrakai](https://github.com/LostKobrakai). 194 | 195 | ## [v0.3.2] - 2021-09-22 196 | 197 | ### Added 198 | 199 | * Support for `Telemetry.Metrics.Sum` type 200 | * Support for filtering CSV records by type with `:type` option 201 | 202 | ## [v0.3.1] - 2021-09-08 203 | 204 | ### Added 205 | 206 | * Plot over the last `x` seconds via the `:last` plot option 207 | * Plot from an absolute time via the `:from` plot option 208 | * Plot to an absolute time via the `:to` plot option 209 | * Print or save metric time series via `Mobius.to_csv/3` 210 | * Remove tracking a metric by dropping it from the metric list passed to Mobius 211 | 212 | ### Changed 213 | 214 | * `Mobius.plot/3` will only show the last 3 minutes of data by default 215 | 216 | ## [v0.3.0] - 2021-8-19 217 | 218 | ### Changed 219 | 220 | * Deleted `Mobius.Charts` module. The functions in this module are now located 221 | in the `Mobius` module. 222 | 223 | ### Removed 224 | 225 | * Support for specifying resolutions. 226 | 227 | ## [v0.2.0] - 2021-8-03 228 | 229 | ### Added 230 | 231 | * `Mobius.Charts` module 232 | * Persistence of historical information on graceful shutdown 233 | * Ability to specify time resolutions for plots 234 | 235 | ### Changed 236 | 237 | * Move `Moblus.plot/0` and `Mobius.info/0` to `Mobius.Charts` module 238 | 239 | ## v0.1.0 - 2021-7-16 240 | 241 | Initial release! 242 | 243 | [v0.6.1]: https://github.com/mattludwigs/mobius/compare/v0.6.0...v0.6.1 244 | [v0.6.0]: https://github.com/mattludwigs/mobius/compare/v0.5.1...v0.6.0 245 | [v0.5.1]: https://github.com/mattludwigs/mobius/compare/v0.5.0...v0.5.1 246 | [v0.5.0]: https://github.com/mattludwigs/mobius/compare/v0.4.0...v0.5.0 247 | [v0.4.0]: https://github.com/mattludwigs/mobius/compare/v0.3.7...v0.4.0 248 | [v0.3.7]: https://github.com/mattludwigs/mobius/compare/v0.3.6...v0.3.7 249 | [v0.3.6]: https://github.com/mattludwigs/mobius/compare/v0.3.5...v0.3.6 250 | [v0.3.5]: https://github.com/mattludwigs/mobius/compare/v0.3.4...v0.3.5 251 | [v0.3.4]: https://github.com/mattludwigs/mobius/compare/v0.3.3...v0.3.4 252 | [v0.3.3]: https://github.com/mattludwigs/mobius/compare/v0.3.2...v0.3.3 253 | [v0.3.2]: https://github.com/mattludwigs/mobius/compare/v0.3.1...v0.3.2 254 | [v0.3.1]: https://github.com/mattludwigs/mobius/compare/v0.3.0...v0.3.1 255 | [v0.3.0]: https://github.com/mattludwigs/mobius/compare/v0.2.0...v0.3.0 256 | [v0.2.0]: https://github.com/mattludwigs/mobius/compare/v0.1.0...v0.2.0 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mobius 2 | 3 | [![CircleCI](https://circleci.com/gh/mobius-home/mobius/tree/main.svg?style=svg)](https://circleci.com/gh/mobius-home/mobius/tree/main) 4 | 5 | ![Mobius](assets/mobius-name.png) 6 | 7 | Library for localized telemetry metrics 8 | 9 | ## Installation 10 | 11 | ```elixir 12 | def deps do 13 | [ 14 | {:mobius, "~> 0.6.1"} 15 | ] 16 | end 17 | ``` 18 | 19 | ## Usage 20 | 21 | Add `Mobius` to your supervision tree and pass in the metrics you want to track. 22 | 23 | ```elixir 24 | def start(_type, _args) do 25 | metrics = [ 26 | Metrics.last_value("my.telemetry.event"), 27 | ] 28 | 29 | children = [ 30 | # ... other children .... 31 | {Mobius, metrics: metrics} 32 | # ... other children .... 33 | ] 34 | 35 | # See https://hexdocs.pm/elixir/Supervisor.html 36 | # for other strategies and supported options 37 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 38 | Supervisor.start_link(children, opts) 39 | end 40 | ``` 41 | 42 | ### Quick tips 43 | 44 | To see a view of the current metrics you can use `Mobius.info/0`: 45 | 46 | ```elixir 47 | iex> Mobius.info() 48 | Metric Name: vm.memory.total 49 | Tags: %{} 50 | last_value: 83952736 51 | ``` 52 | 53 | To plot a metric measurement over time you can use `Mobius.Exports.plot/4`: 54 | 55 | ```elixir 56 | iex> Mobius.Exports.plot("vm.memory.total", :last_value, %{}) 57 | Metric Name: vm.memory.total, Tags: %{} 58 | 59 | 34355808.00 ┤ 60 | 34253736.73 ┤ ╭──╮ ╭────╮ 61 | 34151665.45 ┤ ╭────╯ ╰────╯ ╰─── 62 | 34049594.18 ┤ ╭────╮ ╭────╮ ╭────╯ 63 | 33947522.91 ┤ │ ╰────╯ ╰─╯ 64 | 33845451.64 ┤ │ 65 | 33743380.36 ┤ ╭────╯ 66 | 33641309.09 ┤ │ 67 | 33539237.82 ┤ │ 68 | 33437166.55 ┤ │ 69 | 33335095.27 ┼────╯ 70 | ``` 71 | 72 | ### Configure persistence directory 73 | 74 | By default Mobius will try to save metric data for all resolutions and the 75 | current value when the erlang system exits gracefully. This makes Mobius useful 76 | for Nerves devices that have to reboot after doing a planned firmware update. 77 | The default direction Mobius will try to persist data to is the `/data` 78 | directory as this is friendly to Nerves devices. If you want Mobius to store 79 | data in a different location you can pass that into Mobius when you start it: 80 | 81 | ```elixir 82 | 83 | children = [ 84 | # ... other children ... 85 | {Mobius, metrics: metrics(), persistence_dir: "/tmp"} 86 | # ... other children ... 87 | ] 88 | 89 | def metrics() do 90 | [ 91 | Metrics.last_value("vm.memory.total", unit: {:byte, :kilobyte}) 92 | ] 93 | end 94 | ``` 95 | 96 | ### Saving / Autosaving metrics data 97 | 98 | By default the metrics data is persisted on a normal shutdown. However, data 99 | will not be persisted during a sudden shutdown, eg Control-C in IEX, kill, 100 | sudden power off. 101 | 102 | It's possible to manually call Mobius.save/1 to force an interim write of the 103 | persistence data. 104 | 105 | This can be automated by passing `autosave_interval` to Mobius 106 | 107 | ```elixir 108 | def start(_type, _args) do 109 | metrics = [ 110 | Metrics.last_value("my.telemetry.event.measurement"), 111 | ] 112 | 113 | children = [ 114 | # ... other children .... 115 | {Mobius, metrics: metrics, autosave_interval: 60} # auto save every 60 seconds 116 | # ... other children .... 117 | ] 118 | 119 | # See https://hexdocs.pm/elixir/Supervisor.html 120 | # for other strategies and supported options 121 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 122 | Supervisor.start_link(children, opts) 123 | end 124 | ``` 125 | 126 | ### Exporting data 127 | 128 | The `Mobius.Exports` module provide functions for exporting the data in a couple 129 | different formats. 130 | 131 | 1. CSV 132 | 2. Series 133 | 3. Line plot 134 | 4. Mobius Binary Format 135 | 136 | ```elixir 137 | # export as CSV string 138 | Mobius.Exports.csv("vm.memory.total", :last_value, %{}) 139 | 140 | # export as series list 141 | Mobius.Exports.series("vm.memory.total", :last_value, %{}) 142 | 143 | # export as mobius binary format 144 | Mobius.Exports.mbf() 145 | ``` 146 | 147 | The Mobius Binary Format (MBF) is a binary string that has encoded and 148 | compressed all the historical metrics that mobius current has. This is 149 | most useful for preparing metrics to send off to another system. To parse 150 | the binary format you can use `Mobius.Exports.parse_mbf/1`. 151 | 152 | For each of these you can see the `Mobius.Exports` module for more details. 153 | 154 | ### Report metrics to a remote server 155 | 156 | Mobius allows sending metrics to a remote server. You can do this by passing the 157 | `:remote_reporter` option to Mobius. This is a module that implements the 158 | `Mobius.RemoteReporter` behaviour. Optionally, you can pass the 159 | `:remote_report_interval` option to specify how often to report metrics, by 160 | default this is every 1 minute. 161 | 162 | ### Events 163 | 164 | In a system we want to track metrics and events. Metrics are measurements 165 | tracked at a regular interval. These could in cloud CPU and memory usage or 166 | something like bytes transmitted over an LTE connection. Events are things 167 | tracked at irregular intervals that might not have a measurement. Events are 168 | necessary to pin point moments in time that something of interest happens that 169 | isn't necessarily a measurement. For example, firmware updates and interface 170 | connections. 171 | 172 | Events are good to track things that happen at particular time that are enriched 173 | by extra data, whereas metrics are good for understand single piece of data over 174 | time. 175 | 176 | You can listen for raw telemetry events by passing a list of event names to 177 | Mobius. 178 | 179 | ```elixir 180 | def start(_type, _args) do 181 | events = [ 182 | "a.button.was.pressed" 183 | ] 184 | 185 | children = [ 186 | # ... other children .... 187 | {Mobius, events: events, autosave_interval: 60} # auto save every 60 seconds 188 | # ... other children .... 189 | ] 190 | 191 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 192 | Supervisor.start_link(children, opts) 193 | end 194 | ``` 195 | 196 | The above example will listen for that telemetry event and save that into 197 | Mobius's event log. 198 | 199 | Event configurations can take different options: 200 | 201 | * `:tags` - list of tag names to save with the event 202 | * `:measurement_values` - a function that will receive each measurement that 203 | allows for data processing before storing the event in the event log 204 | * `:group` - an atom that defines the event group, this will allow for filtering 205 | on particular types of events for example: `:network`. Default is `:default` 206 | 207 | For example if a measurement is reported in naive time and you want to convert 208 | that to seconds you can do that this way: 209 | 210 | ```elixir 211 | def start(_type, _args) do 212 | events = [ 213 | {"a.button.was.pressed", measurement_values: &process_button_measurements/1} 214 | ] 215 | 216 | children = [ 217 | # ... other children .... 218 | {Mobius, events: events, autosave_interval: 60} # auto save every 60 seconds 219 | # ... other children .... 220 | ] 221 | 222 | opts = [strategy: :one_for_one, name: MyApp.Supervisor] 223 | Supervisor.start_link(children, opts) 224 | end 225 | 226 | defp process_button_measurement({:system_time, sys_time}) do 227 | System.convert_time(sys_time, :naive, :second) 228 | end 229 | 230 | defp process_button_measurement({_, value}), do: value 231 | ``` 232 | 233 | A good resource for understanding telemetry data and some of the differences 234 | between events and metrics is [New Relic's MELT 101 post](https://newrelic.com/platform/telemetry-data-101). 235 | 236 | ### Clocks 237 | 238 | For systems that lack battery-backed real-time clock which will advance the 239 | clock at startup to a reasonable guess, the early events will have a timestamp 240 | that do not make much sense. Mobius allows you to pass the `clock` argument 241 | which is a module that implements the `Mobius.Clock` behaviour. This behavior 242 | has one callback: `synchronized?/0` which returns a boolean. 243 | 244 | If a clock implementation is provide, Mobius will wait for the clock to 245 | synchronize before including any events into the logs. Once the clock is 246 | synchronized Mobius will make a best effort attempt to adjust early event 247 | timestamps to reflect the actual time the event occurred and will then include 248 | the events into the event log. 249 | 250 | If no clock implementation is provided, Mobius will assume the clock is 251 | synchronized and that it can trust the provided timestamps of early events. 252 | 253 | For Nerves devices, the NervesTime package can be used: 254 | 255 | `{Mobius, metrics: my_metrics, events: my_events, clock: NervesTime}` 256 | -------------------------------------------------------------------------------- /assets/m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mobius-home/mobius/83b91d8ab95d8dc523ecb53d5ad683dab3333d9b/assets/m.png -------------------------------------------------------------------------------- /assets/mobius-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mobius-home/mobius/83b91d8ab95d8dc523ecb53d5ad683dab3333d9b/assets/mobius-name.png -------------------------------------------------------------------------------- /example/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | example-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `example` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:example, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/example](https://hexdocs.pm/example). 21 | 22 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | def simulate_vintage_qmi_connection_event(ifname, connection_status) do 3 | :telemetry.execute( 4 | [:vintage_net_qmi, :connection], 5 | %{}, 6 | %{ifname: ifname, status: connection_status} 7 | ) 8 | end 9 | 10 | def simulate_vintage_qmi_connection_end_event(ifname, connection_status) do 11 | duration = :rand.uniform(100) * 10_000 12 | 13 | :telemetry.execute( 14 | [:vintage_net_qmi, :connection, :end], 15 | %{duration: duration}, 16 | %{ifname: ifname, status: connection_status} 17 | ) 18 | end 19 | 20 | def inc(ifname \\ "wwan0") do 21 | :telemetry.execute( 22 | [:example, :inc], 23 | %{duration: 100}, 24 | %{ifname: ifname} 25 | ) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /example/lib/example/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | alias Telemetry.Metrics 9 | 10 | @impl true 11 | def start(_type, _args) do 12 | metrics = [ 13 | Metrics.counter("example.inc.count", tags: [:ifname]), 14 | Metrics.last_value("example.inc.duration", tags: [:ifname]), 15 | Metrics.last_value("vm.memory.total"), 16 | Metrics.summary("vm.memory.total") 17 | ] 18 | 19 | # custom database that tracks seconds resolution for 5 minutes 20 | database = Mobius.RRD.new(seconds: 300) 21 | 22 | children = [ 23 | # Starts a worker by calling: Example.Worker.start_link(arg) 24 | # {Example.Worker, arg} 25 | {Mobius, 26 | metrics: metrics, 27 | persistence_dir: "/tmp", 28 | database: database, 29 | remote_reporter: Mobius.RemoteReporters.LoggerReporter, 30 | remote_report_interval: 10_000} 31 | ] 32 | 33 | # See https://hexdocs.pm/elixir/Supervisor.html 34 | # for other strategies and supported options 35 | opts = [strategy: :one_for_one, name: Example.Supervisor] 36 | Supervisor.start_link(children, opts) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {Example.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:mobius, path: "../"}, 26 | {:telemetry_poller, "~> 0.5.1"}, 27 | {:telemetry_metrics, "~> 0.6.0"}, 28 | {:telemetry, "~> 0.4.3"} 29 | ] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "circular_buffer": {:hex, :circular_buffer, "0.4.0", "a51ea76bb03c4a38207934264bcc600018ead966728ca80da731458c5f940f8b", [:mix], [], "hexpm", "c604b19f2101982b63264e2ed90c6fb0fe502540b6af83ce95135ac9b6f2d847"}, 3 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 4 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.0", "da9d49ee7e6bb1c259d36ce6539cd45ae14d81247a2b0c90edf55e2b50507f7b", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5cfe67ad464b243835512aa44321cee91faed6ea868d7fb761d7016e02915c3d"}, 5 | "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, 6 | } 7 | -------------------------------------------------------------------------------- /example/test/example_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleTest do 2 | use ExUnit.Case 3 | doctest Example 4 | 5 | test "greets the world" do 6 | assert Example.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/mobius.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius do 2 | @moduledoc """ 3 | Localized metrics reporter 4 | """ 5 | 6 | use Supervisor 7 | 8 | alias Mobius.{Event, EventLog, MetricsTable, ReportServer, Scraper, Summary} 9 | 10 | alias Telemetry.Metrics 11 | 12 | @default_args [mobius_instance: :mobius, persistence_dir: "/data", autosave_interval: nil] 13 | 14 | @type time_unit() :: :second | :minute | :hour | :day 15 | 16 | @typedoc """ 17 | A function to process an event's measurements 18 | 19 | This will be called on each measurement and will receive a tuple where the 20 | first element is the name of the measurement and the second element is the 21 | value. This function can process the value and return a new one. 22 | """ 23 | @type event_measurement_values() :: ({atom(), term()} -> term()) 24 | 25 | @typedoc """ 26 | Options you can pass an event 27 | 28 | These options only apply to the `:event` argument to Mobius. If you want 29 | to track metrics please see the `:metrics` argument to Mobius. 30 | 31 | * `:tags` - list of tag names to save with the event 32 | * `:measurement_values` - a function that will receive each measurement that 33 | allows for data processing before storing the event in the event log 34 | * `:group` - an atom that defines the event group, this will allow for filtering 35 | on particular types of events for example: `:network`. Default is `:default` 36 | """ 37 | @type event_opt() :: 38 | {:measurement_values, event_measurement_values()} | {:tags, [atom()]} | {:group, atom()} 39 | 40 | @type event_def() :: [binary() | {binary(), keyword()}] 41 | 42 | @typedoc """ 43 | Arguments to Mobius 44 | 45 | * `:name` - the name of the mobius instance (defaults to `:mobius`) 46 | * `:metrics` - list of telemetry metrics for Mobius to track 47 | * `:persistence_dir` - the top level directory where mobius will persist 48 | * `:autosave_interval` - time in seconds between automatic writes of the 49 | persistence data (default disabled) metric information 50 | * `:database` - the `Mobius.RRD.t()` to use. This will default to the default 51 | values found in `Mobius.RRD` 52 | * `:events` - a list of events for mobius to store in the event log 53 | * `:event_log_size` - number of events to store (defaults to 500) 54 | * `:clock` - module that implements the `Mobius.Clock` behaviour 55 | * `:session` - a unique id to distinguish between different ties Mobius has ran 56 | 57 | Mobius sessions allow you collect events to analyze across the different times 58 | mobius ran. A good example of this might be measuring how fast an interface 59 | makes its first connection. You can build averages over run times and measure 60 | connection performance. This will allow you to know on average how fast a 61 | device connects so you can check for increased or decreased performance between 62 | runs. 63 | 64 | By default Mobius will generate an UUID for each run. 65 | """ 66 | @type arg() :: 67 | {:mobius_instance, instance()} 68 | | {:metrics, [Metrics.t()]} 69 | | {:persistence_dir, binary()} 70 | | {:database, Mobius.RRD.t()} 71 | | {:events, [event_def()]} 72 | | {:event_log_size, integer()} 73 | | {:clock, module()} 74 | | {:session, session()} 75 | 76 | @typedoc """ 77 | The name of the Mobius instance 78 | 79 | This is used to store data for a particular set of mobius metrics. 80 | """ 81 | @type instance() :: atom() 82 | 83 | @type metric_type() :: :counter | :last_value | :sum | :summary 84 | 85 | @type session() :: binary() 86 | 87 | @typedoc """ 88 | The name of the metric 89 | 90 | Example: `"vm.memory.total"` 91 | """ 92 | @type metric_name() :: binary() 93 | 94 | @typedoc """ 95 | A single metric data point 96 | 97 | * `:type` - the type of the metric 98 | * `:value` - the value of the measurement for the metric 99 | * `:tags` - a map of the tags for the metric 100 | * `:timestamp` - the naive time in seconds the metric was sampled 101 | * `:name` - the name of the metric 102 | """ 103 | @type metric() :: %{ 104 | type: metric_type(), 105 | value: term(), 106 | tags: map(), 107 | timestamp: integer(), 108 | name: binary() 109 | } 110 | 111 | @type timestamp() :: integer() 112 | 113 | @doc """ 114 | Start Mobius 115 | """ 116 | def start_link(args) do 117 | Supervisor.start_link(__MODULE__, ensure_args(args), name: name(args[:mobius_instance])) 118 | end 119 | 120 | defp name(instance) do 121 | Module.concat(__MODULE__.Supervisor, instance) 122 | end 123 | 124 | @impl Supervisor 125 | def init(args) do 126 | mobius_persistence_path = Path.join(args[:persistence_dir], to_string(args[:mobius_instance])) 127 | args = Keyword.put_new(args, :session, UUID.uuid4()) 128 | 129 | case ensure_mobius_persistence_dir(mobius_persistence_path) do 130 | :ok -> 131 | args = 132 | args 133 | |> Keyword.put(:persistence_dir, mobius_persistence_path) 134 | |> Keyword.put_new(:database, Mobius.RRD.new()) 135 | 136 | MetricsTable.init(args) 137 | 138 | children = 139 | [ 140 | {Mobius.TimeServer, args}, 141 | {Mobius.MetricsTable.Monitor, args}, 142 | {Mobius.EventsServer, args}, 143 | {Mobius.Registry, args}, 144 | {Mobius.Scraper, args}, 145 | {Mobius.ReportServer, args} 146 | ] 147 | |> maybe_enable_autosave(args) 148 | 149 | Supervisor.init(children, strategy: :one_for_one) 150 | 151 | {:error, :enoent} -> 152 | raise("persistence_path does not exist: #{mobius_persistence_path}") 153 | 154 | {:error, msg} -> 155 | raise("could not start mobius: #{msg}") 156 | end 157 | end 158 | 159 | defp ensure_args(args) do 160 | Keyword.merge(@default_args, args) 161 | end 162 | 163 | defp ensure_mobius_persistence_dir(persistence_path) do 164 | case File.mkdir_p(persistence_path) do 165 | :ok -> 166 | :ok 167 | 168 | {:error, :eexist} -> 169 | :ok 170 | 171 | error -> 172 | error 173 | end 174 | end 175 | 176 | defp maybe_enable_autosave(children, args) do 177 | if is_number(args[:autosave_interval]) and args[:autosave_interval] > 0 do 178 | children ++ [{Mobius.AutoSave, args}] 179 | else 180 | children 181 | end 182 | end 183 | 184 | @doc """ 185 | Get the current metric information 186 | 187 | If you configured Mobius to use a different name then you can pass in your 188 | custom name to ensure Mobius requests the metrics from the right place. 189 | """ 190 | @spec info(Mobius.instance() | nil) :: :ok 191 | def info() do 192 | info(@default_args[:mobius_instance]) 193 | end 194 | 195 | def info(instance) do 196 | instance 197 | |> MetricsTable.get_entries() 198 | |> Enum.group_by(fn {metric_name, _type, _value, meta} -> {metric_name, meta} end) 199 | |> Enum.each(fn {{metric_name, meta}, metrics} -> 200 | reports = 201 | Enum.map(metrics, fn {_metric_name, type, value, _meta} -> 202 | "#{to_string(type)}: #{inspect(format_value(type, value))}\n" 203 | end) 204 | 205 | [ 206 | "Metric Name: ", 207 | metric_name, 208 | "\n", 209 | "Tags: #{inspect(meta)}\n", 210 | reports 211 | ] 212 | |> IO.puts() 213 | end) 214 | end 215 | 216 | defp format_value(:summary, summary_data) do 217 | Summary.calculate(summary_data) 218 | end 219 | 220 | defp format_value(_, value) do 221 | value 222 | end 223 | 224 | @doc """ 225 | Persist the metrics to disk 226 | """ 227 | @spec save(instance()) :: :ok | {:error, reason :: term()} 228 | def save(), do: save(@default_args[:mobius_instance]) 229 | 230 | def save(instance) do 231 | start_t = System.monotonic_time() 232 | prefix = [:mobius, :save] 233 | 234 | :telemetry.execute(prefix ++ [:start], %{system_time: System.system_time()}, %{ 235 | instance: instance 236 | }) 237 | 238 | with :ok <- Scraper.save(instance), 239 | :ok <- MetricsTable.Monitor.save(instance), 240 | :ok <- EventLog.save(instance: instance) do 241 | duration = System.monotonic_time() - start_t 242 | :telemetry.execute(prefix ++ [:stop], %{duration: duration}, %{instance: instance}) 243 | 244 | :ok 245 | else 246 | error -> 247 | duration = System.monotonic_time() - start_t 248 | 249 | :telemetry.execute( 250 | prefix ++ [:exception], 251 | %{reason: inspect(error), duration: duration}, 252 | %{instance: instance} 253 | ) 254 | 255 | error 256 | end 257 | end 258 | 259 | @doc """ 260 | Get the latest metrics 261 | 262 | The latest metrics are the metrics recorded between the last query for the 263 | metrics and the query for the metrics that is being called. 264 | """ 265 | @spec get_latest_metrics(Mobius.instance()) :: [metric()] 266 | def get_latest_metrics(instance \\ :mobius) do 267 | ReportServer.get_latest_metrics(instance) 268 | end 269 | 270 | @doc """ 271 | Get the latest events 272 | 273 | The latest events are the events recorded between the last query for the 274 | events and the query for the events that is being called. 275 | """ 276 | @spec get_latest_events(Mobius.instance()) :: [Event.t()] 277 | def get_latest_events(instance \\ :mobius) do 278 | ReportServer.get_latest_events(instance) 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/mobius/asciichart.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file 2 | defmodule Mobius.Asciichart do 3 | @moduledoc false 4 | 5 | # ASCII chart generation. 6 | 7 | # This module was taking from [sndnv's elixir asciichart package](https://github.com/sndnv/asciichart) 8 | # and slightly modified to meet the needs of this project. 9 | 10 | # Ported to Elixir from [https://github.com/kroitor/asciichart](https://github.com/kroitor/asciichart) 11 | 12 | @doc ~S""" 13 | Generates a chart for the specified list of numbers. 14 | 15 | Optionally, the following settings can be provided: 16 | * :offset - the number of characters to set as the chart's offset (left) 17 | * :height - adjusts the height of the chart 18 | * :padding - one or more characters to use for the label's padding (left) 19 | 20 | ## Examples 21 | iex> Asciichart.plot([1, 2, 3, 3, 2, 1]) 22 | {:ok, "3.00 ┤ ╭─╮ \n2.00 ┤╭╯ ╰╮ \n1.00 ┼╯ ╰ \n "} 23 | 24 | # should render as 25 | 26 | 3.00 ┤ ╭─╮ 27 | 2.00 ┤╭╯ ╰╮ 28 | 1.00 ┼╯ ╰ 29 | 30 | iex> Asciichart.plot([1, 2, 6, 6, 2, 1], height: 2) 31 | {:ok, "6.00 ┼ \n3.50 ┤ ╭─╮ \n1.00 ┼─╯ ╰─ \n "} 32 | 33 | # should render as 34 | 35 | 6.00 ┼ 36 | 3.50 ┤ ╭─╮ 37 | 1.00 ┼─╯ ╰─ 38 | 39 | iex> Asciichart.plot([1, 2, 5, 5, 4, 3, 2, 100, 0], height: 3, offset: 10, padding: "__") 40 | {:ok, " 100.00 ┼ ╭╮ \n _50.00 ┤ ││ \n __0.00 ┼──────╯╰ \n "} 41 | 42 | # should render as 43 | 44 | 100.00 ┼ ╭╮ 45 | _50.00 ┤ ││ 46 | __0.00 ┼──────╯╰ 47 | 48 | 49 | # Rendering of empty charts is not supported 50 | 51 | iex> Asciichart.plot([]) 52 | {:error, "No data"} 53 | """ 54 | def plot(series, cfg \\ %{}) do 55 | case series do 56 | [] -> 57 | {:error, "No data"} 58 | 59 | [_ | _] -> 60 | minimum = Enum.min(series) 61 | maximum = Enum.max(series) 62 | 63 | interval = abs(maximum - minimum) 64 | offset = cfg[:offset] || 3 65 | height = if cfg[:height], do: cfg[:height] - 1, else: interval 66 | padding = cfg[:padding] || " " 67 | ratio = if interval == 0, do: 1, else: height / interval 68 | min2 = safe_floor(minimum * ratio) 69 | max2 = safe_ceil(maximum * ratio) 70 | 71 | intmin2 = trunc(min2) 72 | intmax2 = trunc(max2) 73 | 74 | rows = abs(intmax2 - intmin2) 75 | width = length(series) + offset 76 | 77 | rows_denom = max(1, rows) 78 | 79 | # empty space 80 | result = 81 | 0..(rows + 1) 82 | |> Enum.map(fn x -> 83 | {x, 0..width |> Enum.map(fn y -> {y, " "} end) |> Enum.into(%{})} 84 | end) 85 | |> Enum.into(%{}) 86 | 87 | max_label_size = 88 | (maximum / 1) 89 | |> Float.round(2) 90 | |> :erlang.float_to_binary(decimals: 2) 91 | |> String.length() 92 | 93 | min_label_size = 94 | (minimum / 1) 95 | |> Float.round(2) 96 | |> :erlang.float_to_binary(decimals: 2) 97 | |> String.length() 98 | 99 | label_size = max(min_label_size, max_label_size) 100 | 101 | # axis and labels 102 | result = 103 | intmin2..intmax2 104 | |> Enum.reduce(result, fn y, map -> 105 | label = 106 | (maximum - (y - intmin2) * interval / rows_denom) 107 | |> Float.round(2) 108 | |> :erlang.float_to_binary(decimals: 2) 109 | |> String.pad_leading(label_size, padding) 110 | 111 | updated_map = put_in(map[y - intmin2][max(offset - String.length(label), 0)], label) 112 | put_in(updated_map[y - intmin2][offset - 1], if(y == 0, do: "┼", else: "┤")) 113 | end) 114 | 115 | # first value 116 | y0 = trunc(Enum.at(series, 0) * ratio - min2) 117 | result = put_in(result[rows - y0][offset - 1], "┼") 118 | 119 | # plot the line 120 | result = 121 | 0..(length(series) - 2) 122 | |> Enum.reduce(result, fn x, map -> 123 | y0 = trunc(Enum.at(series, x + 0) * ratio - intmin2) 124 | y1 = trunc(Enum.at(series, x + 1) * ratio - intmin2) 125 | 126 | if y0 == y1 do 127 | put_in(map[rows - y0][x + offset], "─") 128 | else 129 | updated_map = 130 | put_in( 131 | map[rows - y1][x + offset], 132 | if(y0 > y1, do: "╰", else: "╭") 133 | ) 134 | 135 | updated_map = 136 | put_in( 137 | updated_map[rows - y0][x + offset], 138 | if(y0 > y1, do: "╮", else: "╯") 139 | ) 140 | 141 | (min(y0, y1) + 1)..max(y0, y1) 142 | |> Enum.drop(-1) 143 | |> Enum.reduce(updated_map, fn y, map -> 144 | put_in(map[rows - y][x + offset], "│") 145 | end) 146 | end 147 | end) 148 | 149 | # ensures cell order, regardless of map sizes 150 | result = 151 | result 152 | |> Enum.sort_by(fn {k, _} -> k end) 153 | |> Enum.map(fn {_, x} -> 154 | x 155 | |> Enum.sort_by(fn {k, _} -> k end) 156 | |> Enum.map(fn {_, y} -> y end) 157 | |> Enum.join() 158 | end) 159 | |> Enum.join("\n") 160 | 161 | {:ok, result} 162 | end 163 | end 164 | 165 | defp safe_floor(n) when is_integer(n) do 166 | n 167 | end 168 | 169 | defp safe_floor(n) when is_float(n) do 170 | Float.floor(n) 171 | end 172 | 173 | defp safe_ceil(n) when is_integer(n) do 174 | n 175 | end 176 | 177 | defp safe_ceil(n) when is_float(n) do 178 | Float.ceil(n) 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/mobius/auto_save.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.AutoSave do 2 | @moduledoc false 3 | 4 | # Trivial module to call our save function on a regular basis 5 | 6 | use GenServer 7 | 8 | @spec start_link([Mobius.arg()]) :: GenServer.on_start() 9 | def start_link(args) do 10 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 11 | end 12 | 13 | defp name(instance) do 14 | Module.concat(__MODULE__, instance) 15 | end 16 | 17 | @impl GenServer 18 | def init(args) do 19 | state = 20 | args 21 | |> Keyword.take([:autosave_interval, :mobius_instance, :persistence_dir]) 22 | |> Enum.into(%{}) 23 | 24 | _ = :timer.send_interval(state.autosave_interval * 1_000, self(), :auto_save) 25 | 26 | {:ok, state} 27 | end 28 | 29 | @impl GenServer 30 | def handle_info(:auto_save, state) do 31 | _ = Mobius.save(state.mobius_instance) 32 | {:noreply, state} 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mobius/clock.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Clock do 2 | @moduledoc """ 3 | Behaviour for Mobius to check if the clock is set 4 | 5 | On systems that need to set the time after boot events, metrics might 6 | report a nonsensical timestamp. Providing a clock implementation allows Mobius 7 | to make time adjustments on data received before the clock was set. 8 | 9 | If no clock implementation is provided no time adjustments will be made. 10 | 11 | For Nerves devices, [NervesTime](https://hex.pm/packages/nerves_time) can be 12 | used to track time synchronization. 13 | 14 | ```elixir 15 | {Mobius, clock: NervesTime} 16 | ``` 17 | 18 | The time adjustments are best effort and might not be 100% exact, but this should 19 | only affect events that take place during the early stages of system boot. 20 | """ 21 | 22 | @doc """ 23 | Callback to check if the clock is synchronized 24 | """ 25 | @callback synchronized?() :: boolean() 26 | end 27 | -------------------------------------------------------------------------------- /lib/mobius/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Event do 2 | @moduledoc """ 3 | An single event 4 | """ 5 | 6 | @typedoc """ 7 | The name of the event 8 | """ 9 | @type name() :: binary() | [atom()] 10 | 11 | @typedoc """ 12 | Options for creating a new event 13 | """ 14 | @type new_opt() :: {:group, atom()} | {:timestamp, integer()} 15 | 16 | @typedoc """ 17 | An event 18 | """ 19 | @type t() :: %__MODULE__{ 20 | name: name(), 21 | measurements: map(), 22 | tags: map(), 23 | group: atom(), 24 | timestamp: pos_integer() | nil, 25 | session: Mobius.session() 26 | } 27 | 28 | defstruct name: nil, 29 | measurements: %{}, 30 | tags: %{}, 31 | group: :default, 32 | timestamp: nil, 33 | session: nil 34 | 35 | @doc """ 36 | Create a new event 37 | """ 38 | @spec new(Mobius.session(), name(), map(), map(), [new_opt()]) :: t() 39 | def new(session, name, measurements, tags, opts \\ []) do 40 | group = opts[:group] || :default 41 | timestamp = get_timestamp(opts) 42 | 43 | %__MODULE__{ 44 | name: name_to_string(name), 45 | measurements: measurements, 46 | timestamp: timestamp, 47 | tags: tags, 48 | group: group, 49 | session: session 50 | } 51 | end 52 | 53 | defp get_timestamp(opts) do 54 | case opts[:timestamp] do 55 | nil -> System.system_time(:second) 56 | timestamp -> timestamp 57 | end 58 | end 59 | 60 | def set_timestamp(event, timestamp) do 61 | %{event | timestamp: timestamp} 62 | end 63 | 64 | defp name_to_string(name) when is_list(name) do 65 | Enum.join(name, ".") 66 | end 67 | 68 | defp name_to_string(name) when is_binary(name) do 69 | name 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/mobius/event_log.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.EventLog do 2 | @moduledoc """ 3 | API for working with the event log 4 | """ 5 | 6 | alias Mobius.{Event, EventsServer} 7 | 8 | @event_log_binary_format_version 1 9 | 10 | @typedoc """ 11 | Options to query the event log 12 | """ 13 | @type opt() :: {:from, integer()} | {:to, integer()} | {:instance, Mobius.instance()} 14 | 15 | @doc """ 16 | List the events in the event log 17 | """ 18 | @spec list([opt()]) :: [Event.t()] 19 | def list(opts \\ []) do 20 | instance = opts[:instance] || :mobius 21 | EventsServer.list(instance, opts) 22 | end 23 | 24 | @doc """ 25 | Return the event log in the Mobius binary format 26 | """ 27 | @spec to_binary([opt()]) :: binary() 28 | def to_binary(opts \\ []) do 29 | opts 30 | |> list() 31 | |> events_to_binary() 32 | end 33 | 34 | @doc """ 35 | Turn a list of Events into a binary 36 | """ 37 | @spec events_to_binary([Event.t()]) :: binary() 38 | def events_to_binary(events) do 39 | bin = :erlang.term_to_binary(events) 40 | 41 | <<@event_log_binary_format_version, bin::binary>> 42 | end 43 | 44 | @doc """ 45 | Save the current state of the event log to disk 46 | """ 47 | @spec save([opt()]) :: :ok 48 | def save(opts \\ []) do 49 | instance = opts[:instance] || :mobius 50 | bin = to_binary(opts) 51 | 52 | EventsServer.save(instance, bin) 53 | end 54 | 55 | @doc """ 56 | Parse the Mobius binary formatted event log 57 | """ 58 | @spec parse(binary()) :: {:ok, [Event.t()]} | {:error, atom()} 59 | def parse(<<0x01, event_log_bin::binary>>) do 60 | {:ok, :erlang.binary_to_term(event_log_bin)} 61 | end 62 | 63 | def parse(_binary) do 64 | {:error, :invalid_binary_format} 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/mobius/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Events do 2 | @moduledoc false 3 | 4 | alias Mobius.MetricsTable 5 | 6 | alias Telemetry.Metrics 7 | alias Telemetry.Metrics.{Counter, LastValue, Sum, Summary} 8 | 9 | require Logger 10 | 11 | @typedoc """ 12 | The configuration that is passed to every handle events call 13 | 14 | * `:table` - the metrics table name used to store metrics 15 | * `:event_opts` - the list of options to configure the event 16 | """ 17 | @type event_handler_config() :: %{ 18 | table: Mobius.instance(), 19 | metrics: [Metrics.t()] 20 | } 21 | 22 | @typedoc """ 23 | The configuration that is passed to every handle metric call 24 | 25 | * `:table` - the metrics table name used to store metrics 26 | * `:metrics` - the list of metrics that Mobius is to listen for 27 | """ 28 | @type metric_handler_config() :: %{ 29 | table: Mobius.instance(), 30 | metrics: [Metrics.t()] 31 | } 32 | 33 | @doc """ 34 | Handle telemetry events 35 | """ 36 | @spec handle_metrics( 37 | :telemetry.event_name(), 38 | :telemetry.event_measurements(), 39 | :telemetry.event_metadata(), 40 | metric_handler_config() 41 | ) :: :ok 42 | def handle_metrics(_event, measurements, metadata, config) do 43 | for metric <- config.metrics do 44 | try do 45 | measurement = extract_measurement(metric, measurements, metadata) 46 | 47 | if !is_nil(measurement) and keep?(metric, metadata) do 48 | tags = extract_tags(metric, metadata) 49 | 50 | handle_metric(metric, measurement, tags, config) 51 | end 52 | rescue 53 | e -> 54 | Logger.error("Could not format metric #{inspect(metric)}") 55 | Logger.error(Exception.format(:error, e, __STACKTRACE__)) 56 | end 57 | end 58 | 59 | :ok 60 | end 61 | 62 | # Counter only ever increments by one, regardless of metric value 63 | defp handle_metric(%Counter{} = metric, _value, labels, config) do 64 | MetricsTable.inc_counter(config.table, metric.name, labels) 65 | end 66 | 67 | defp handle_metric(%LastValue{} = metric, value, labels, config) do 68 | MetricsTable.put(config.table, metric.name, :last_value, value, labels) 69 | end 70 | 71 | defp handle_metric(%Sum{} = metric, value, labels, config) do 72 | MetricsTable.update_sum(config.table, metric.name, value, labels) 73 | end 74 | 75 | defp handle_metric(%Summary{} = metric, value, labels, config) do 76 | MetricsTable.put(config.table, metric.name, :summary, value, labels) 77 | end 78 | 79 | defp keep?(%{keep: nil}, _metadata), do: true 80 | defp keep?(metric, metadata), do: metric.keep.(metadata) 81 | 82 | defp extract_measurement(%Counter{}, _measurements, _metadata) do 83 | 1 84 | end 85 | 86 | defp extract_measurement(metric, measurements, metadata) do 87 | case metric.measurement do 88 | fun when is_function(fun, 1) -> fun.(measurements) 89 | fun when is_function(fun, 2) -> fun.(measurements, metadata) 90 | key -> measurements[key] 91 | end 92 | end 93 | 94 | defp extract_tags(metric, metadata) do 95 | tag_values = metric.tag_values.(metadata) 96 | Map.take(tag_values, metric.tags) 97 | end 98 | 99 | @doc """ 100 | Handle telemetry events 101 | """ 102 | @spec handle_event( 103 | :telemetry.event_name(), 104 | :telemetry.event_measurements(), 105 | :telemetry.event_metadata(), 106 | event_handler_config() 107 | ) :: :ok 108 | def handle_event(event, measurements, metadata, config) do 109 | try do 110 | process_event( 111 | config.table, 112 | config.session, 113 | event, 114 | measurements, 115 | metadata, 116 | config.event_opts 117 | ) 118 | rescue 119 | e -> 120 | Logger.error("Could not process event #{inspect(event)}") 121 | Logger.error(Exception.format(:error, e, __STACKTRACE__)) 122 | end 123 | 124 | :ok 125 | end 126 | 127 | def process_event(instance, session, event, measurements, metadata, opts) do 128 | measurements = process_measurements(measurements, opts) 129 | tags = get_event_tags(metadata, opts) 130 | 131 | event = Mobius.Event.new(session, event, measurements, tags) 132 | 133 | Mobius.EventsServer.insert(instance, event) 134 | :ok 135 | end 136 | 137 | defp process_measurements(measurements, opts) do 138 | case opts[:measurements_values] do 139 | nil -> 140 | measurements 141 | 142 | values_translator -> 143 | Enum.reduce(measurements, %{}, fn {k, _v} = measurement, new_measurements -> 144 | new_value = values_translator.(measurement) 145 | 146 | Map.put(new_measurements, k, new_value) 147 | end) 148 | end 149 | end 150 | 151 | defp get_event_tags(metadata, opts) do 152 | allowed_tags = opts[:tags] || [] 153 | 154 | Enum.reduce(allowed_tags, %{}, fn tag, tags -> 155 | case Map.get(metadata, tag) do 156 | nil -> 157 | tags 158 | 159 | value -> 160 | Map.put(tags, tag, value) 161 | end 162 | end) 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/mobius/events_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.EventsServer do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | require Logger 7 | 8 | alias Mobius.{Event, EventLog, TimeServer} 9 | 10 | @file_name "event_log" 11 | 12 | @doc """ 13 | Start the event log server 14 | """ 15 | @spec start_link([Mobius.arg()]) :: GenServer.on_start() 16 | def start_link(args) do 17 | instance = args[:mobius_instance] || :mobius 18 | GenServer.start_link(__MODULE__, args, name: name(instance)) 19 | end 20 | 21 | defp name(instance) do 22 | Module.concat(__MODULE__, instance) 23 | end 24 | 25 | @doc """ 26 | Insert an event 27 | """ 28 | @spec insert(Mobius.instance(), Event.t()) :: :ok 29 | def insert(instance \\ :mobius, event) do 30 | GenServer.cast(name(instance), {:insert_event, event}) 31 | end 32 | 33 | @doc """ 34 | List the events 35 | """ 36 | @spec list(Mobius.instance(), [EventLog.opt()]) :: [Event.t()] 37 | def list(instance \\ :mobius, opts) do 38 | GenServer.call(name(instance), {:list, opts}) 39 | end 40 | 41 | @doc """ 42 | Save the event log to disk 43 | """ 44 | @spec save(Mobius.instance(), binary()) :: :ok 45 | def save(instance \\ :mobius, binary) do 46 | GenServer.cast(name(instance), {:save, binary}) 47 | end 48 | 49 | @doc """ 50 | Clear the event log and stored data 51 | """ 52 | @spec clear(Mobius.instance()) :: :ok 53 | def clear(instance \\ :mobius) do 54 | GenServer.cast(name(instance), :reset) 55 | end 56 | 57 | @impl GenServer 58 | def init(args) do 59 | Process.flag(:trap_exit, true) 60 | :ok = TimeServer.register(args[:mobius_instance], self()) 61 | persistence_dir = args[:persistence_dir] 62 | event_log_size = args[:event_log_size] || 500 63 | 64 | cb = make_buffer(persistence_dir, event_log_size) 65 | out_of_time_buffer = make_out_of_time_buffer(args[:mobius_instance]) 66 | 67 | {:ok, 68 | %{ 69 | buffer: cb, 70 | persistence_dir: persistence_dir, 71 | size: event_log_size, 72 | out_of_time_buffer: out_of_time_buffer, 73 | instance: args[:mobius_instance] 74 | }} 75 | end 76 | 77 | defp make_buffer(persistence_dir, log_size) do 78 | path = make_file_path(persistence_dir) 79 | 80 | with {:ok, binary} <- File.read(path), 81 | {:ok, event_log_list} <- EventLog.parse(binary) do 82 | buffer = CircularBuffer.new(log_size) 83 | 84 | Enum.reduce(event_log_list, buffer, fn event, buff -> 85 | CircularBuffer.insert(buff, event) 86 | end) 87 | else 88 | _ -> 89 | CircularBuffer.new(log_size) 90 | end 91 | end 92 | 93 | defp make_out_of_time_buffer(instance) do 94 | if TimeServer.synchronized?(instance) do 95 | nil 96 | else 97 | CircularBuffer.new(100) 98 | end 99 | end 100 | 101 | @impl GenServer 102 | def handle_call({:list, opts}, _from, state) do 103 | {:reply, make_list(state.buffer, opts), state} 104 | end 105 | 106 | @impl GenServer 107 | def handle_cast({:insert_event, event}, state) do 108 | if TimeServer.synchronized?(state.instance) do 109 | new_buffer = CircularBuffer.insert(state.buffer, event) 110 | 111 | {:noreply, %{state | buffer: new_buffer}} 112 | else 113 | out_of_time_buffer = CircularBuffer.insert(state.out_of_time_buffer, event) 114 | {:noreply, %{state | out_of_time_buffer: out_of_time_buffer}} 115 | end 116 | end 117 | 118 | def handle_cast({:save, binary}, state) do 119 | :ok = do_save(binary, state) 120 | 121 | {:noreply, state} 122 | end 123 | 124 | def handle_cast(:reset, state) do 125 | path = make_file_path(state.persistence_dir) 126 | 127 | _ = File.rm(path) 128 | 129 | {:noreply, %{state | buffer: CircularBuffer.new(state.size)}} 130 | end 131 | 132 | @impl GenServer 133 | def handle_info({Mobius.TimeServer, _, _}, %{out_of_time_buffer: nil} = state) do 134 | {:noreply, state} 135 | end 136 | 137 | def handle_info({Mobius.TimeServer, sync_timestamp, adjustment}, state) do 138 | out_of_time_events = CircularBuffer.to_list(state.out_of_time_buffer) 139 | updated = adjust_timestamps(out_of_time_events, sync_timestamp, adjustment) 140 | 141 | updated_buffer = insert_many(updated, state) 142 | 143 | {:noreply, %{state | out_of_time_buffer: nil, buffer: updated_buffer}} 144 | end 145 | 146 | defp adjust_timestamps(events, sync_timestamp, adjustment) do 147 | adjustment_sec = System.convert_time_unit(adjustment, :native, :second) 148 | sync_timestamp_sec = System.convert_time_unit(sync_timestamp, :native, :second) 149 | 150 | Enum.map(events, fn event -> 151 | # this accounts for a race condition between an event being inserted after 152 | # sync and before being notified that the clock synced 153 | if sync_timestamp_sec < event.timestamp do 154 | event 155 | else 156 | updated_ts = event.timestamp + adjustment_sec 157 | Event.set_timestamp(event, updated_ts) 158 | end 159 | end) 160 | end 161 | 162 | defp insert_many(events, state) do 163 | Enum.reduce(events, state.buffer, fn event, buffer -> 164 | CircularBuffer.insert(buffer, event) 165 | end) 166 | end 167 | 168 | @impl GenServer 169 | def terminate(_reason, state) do 170 | events = make_list(state.buffer, []) 171 | bin = EventLog.events_to_binary(events) 172 | 173 | do_save(bin, state) 174 | end 175 | 176 | defp make_list(buffer, opts) do 177 | from = opts[:from] || 0 178 | to = opts[:to] || System.system_time(:second) 179 | 180 | buffer 181 | |> CircularBuffer.to_list() 182 | |> Enum.sort_by(fn event -> event.timestamp end) 183 | |> Enum.filter(fn event -> 184 | event.timestamp >= from && event.timestamp <= to 185 | end) 186 | end 187 | 188 | defp make_file_path(dir) do 189 | Path.join(dir, @file_name) 190 | end 191 | 192 | defp do_save(binary, state) do 193 | path = make_file_path(state.persistence_dir) 194 | 195 | _ = 196 | case File.write(path, binary) do 197 | :ok -> 198 | state 199 | 200 | {:error, reason} -> 201 | Logger.warning("[Mobius]: unable to save event log: #{inspect(reason)}") 202 | end 203 | 204 | :ok 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/mobius/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.DataLoadError do 2 | @moduledoc """ 3 | Used when there is problem loading data into the mobius storage 4 | """ 5 | 6 | defexception [:message, :reason] 7 | 8 | @type t() :: %__MODULE__{ 9 | message: binary(), 10 | reason: atom() 11 | } 12 | 13 | @typedoc """ 14 | Options for making a `DataLoadError` 15 | 16 | - `:reason` - the reason why the data could not be loaded 17 | """ 18 | @type opt() :: {:reason, atom()} 19 | 20 | @impl Exception 21 | def exception(opts) do 22 | reason = Keyword.fetch!(opts, :reason) 23 | 24 | %__MODULE__{ 25 | message: "Unable to load data because of #{inspect(reason)}", 26 | reason: reason 27 | } 28 | end 29 | end 30 | 31 | defmodule Mobius.Exports.UnsupportedMetricError do 32 | @moduledoc """ 33 | Error for trying to export metric types where there is no support in the 34 | export implementation 35 | """ 36 | 37 | defexception [:message, :metric_type] 38 | 39 | @type t() :: %__MODULE__{ 40 | message: binary(), 41 | metric_type: Mobius.metric_type() 42 | } 43 | 44 | @impl Exception 45 | def exception(opts) do 46 | type = Keyword.fetch!(opts, :metric_type) 47 | 48 | %__MODULE__{ 49 | message: "Exporting metrics of type #{inspect(type)} is not supported", 50 | metric_type: type 51 | } 52 | end 53 | end 54 | 55 | defmodule Mobius.Exports.MBFParseError do 56 | @moduledoc """ 57 | Use when there is an error parsing a Mobius Binary Format (MBF) binary 58 | """ 59 | 60 | @type t() :: %__MODULE__{ 61 | message: binary(), 62 | error: atom() 63 | } 64 | 65 | defexception [:message, :error] 66 | 67 | @impl Exception 68 | def exception(error) do 69 | %__MODULE__{ 70 | error: error, 71 | message: "Error parsing mobius binary format binary because #{inspect(error)}" 72 | } 73 | end 74 | end 75 | 76 | defmodule Mobius.FileError do 77 | @moduledoc """ 78 | Used when there is an error conducting file operations 79 | """ 80 | 81 | defexception [:message, :error, :file, :operation] 82 | 83 | @type t() :: %__MODULE__{ 84 | message: binary(), 85 | error: atom(), 86 | file: Path.t(), 87 | operation: binary() 88 | } 89 | 90 | @impl Exception 91 | def exception(opts) do 92 | error = Keyword.fetch!(opts, :error) 93 | file = Keyword.fetch!(opts, :file) 94 | operation = Keyword.fetch!(opts, :operation) 95 | 96 | %__MODULE__{ 97 | error: error, 98 | message: 99 | "Could not #{inspect(operation)} file #{inspect(file)} for reason: {inspect(error)}", 100 | file: file, 101 | operation: operation 102 | } 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/mobius/exports.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exports do 2 | @moduledoc """ 3 | Support retrieving historical data in different formats 4 | 5 | Current formats: 6 | 7 | * CSV 8 | * Series 9 | * Line plot 10 | * Mobius Binary Format (MBF) 11 | 12 | The Mobius Binary Format (MBF) is a format that contains the current state of 13 | all metrics. This binary format is useful for transferring metric information in 14 | a format that other services can parse and use. For more details see `mbf/1`. 15 | """ 16 | 17 | alias Mobius.Asciichart 18 | alias Mobius.Exports.{CSV, Metrics, MobiusBinaryFormat, UnsupportedMetricError} 19 | 20 | @typedoc """ 21 | Options to use when exporting time series metric data 22 | 23 | * `:mobius_instance` - the name of the Mobius instance you are using. Unless 24 | you specified this in your configuration you should be safe to allow this 25 | option to default, which is `:mobius_metrics`. 26 | * `:last` - display data point that have been captured over the last `x` 27 | amount of time. Where `x` is either an integer or a tuple of 28 | `{integer(), time_unit()}`. If you only pass an integer the time unit of 29 | `:seconds` is assumed. By default Mobius will plot the last 3 minutes of 30 | data. 31 | * `:from` - the unix timestamp, in seconds, to start querying from 32 | * `:to` - the unix timestamp, in seconds, to stop querying at 33 | """ 34 | @type export_opt() :: 35 | {:mobius_instance, Mobius.instance()} 36 | | {:from, integer()} 37 | | {:to, integer()} 38 | | {:last, integer() | {integer(), Mobius.time_unit()}} 39 | 40 | @typedoc """ 41 | Options for exporting a CSV 42 | """ 43 | @type csv_export_opt() :: 44 | export_opt() 45 | | {:headers, boolean()} 46 | | {:iodevice, IO.device()} 47 | 48 | @typedoc """ 49 | Metric types that can be exported 50 | 51 | By default you can try to export any `Mobius.metric_type()`, but for the 52 | summary metric type you can specify which summary type you want to export. 53 | """ 54 | @type export_metric_type() :: Mobius.metric_type() | {:summary, atom()} 55 | 56 | @doc """ 57 | Generate a CSV for the metric 58 | 59 | Please see `Mobius.Exporters.CSV` for more information. 60 | 61 | ```elixir 62 | # Return CSV as string 63 | {:ok, csv_string} = Mobius.Exports.csv("vm.memory.total", :last_value, %{}) 64 | 65 | # Write to console 66 | Mobius.Exports.csv("vm.memory.total", :last_value, %{}, iodevice: :stdio) 67 | 68 | # Write to a file 69 | file = File.open("mycsv.csv", [:write]) 70 | :ok = Mobius.Exports.csv("vm.memory.total", :last_value, %{}, iodevice: file) 71 | ``` 72 | """ 73 | @spec csv(binary(), export_metric_type(), map(), [csv_export_opt()]) :: 74 | :ok | {:ok, binary()} | {:error, UnsupportedMetricError.t()} 75 | def csv(metric_name, type, tags, opts \\ []) 76 | 77 | def csv(_metric_name, :summary, _tags, _opts) do 78 | {:error, UnsupportedMetricError.exception(metric_type: :summary)} 79 | end 80 | 81 | def csv(metric_name, type, tags, opts) do 82 | metrics = get_metrics(metric_name, type, tags, opts) 83 | export_opts = build_exporter_opts(metric_name, type, tags, opts) 84 | CSV.export_metrics(metrics, export_opts) 85 | end 86 | 87 | @doc """ 88 | Generates a series that contains the value of the metric 89 | """ 90 | @spec series(String.t(), export_metric_type(), map(), [export_opt()]) :: [integer()] 91 | def series(metric_name, type, tags, opts \\ []) do 92 | metric_name 93 | |> get_metrics(type, tags, opts) 94 | |> Enum.map(& &1.value) 95 | end 96 | 97 | @doc """ 98 | Retrieve the raw metric data from the history store for a given metric. 99 | 100 | Output will be a list of metric values, which will be in the format, eg: 101 | `%{type: :last_value, value: 12, tags: %{interface: "eth0"}, timestamp: 1645107424}` 102 | 103 | If there are tags for the metric you can pass those in the third argument: 104 | 105 | ```elixir 106 | Mobius.Exports.metrics("vm.memory.total", :last_value, %{some: :tag}) 107 | ``` 108 | 109 | By default the filter will display the last 3 minutes of metric history. 110 | 111 | However, you can pass the `:from` and `:to` options to look at a specific 112 | range of time. 113 | 114 | ```elixir 115 | Mobius.Exports.metrics("vm.memory.total", :last_value, %{}, from: 1630619212, to: 1630619219) 116 | ``` 117 | 118 | You can also filter data over the last `x` amount of time. Where x is an 119 | integer. When there is no `time_unit()` provided the unit is assumed to be 120 | `:second`. 121 | 122 | Retrieving data over the last 30 seconds: 123 | 124 | ```elixir 125 | Mobius.Exports.metrics("vm.memory.total", :last_value, %{}, last: 30) 126 | ``` 127 | 128 | Retrieving data over the last 2 hours: 129 | 130 | ```elixir 131 | Mobius.Exports.metrics("vm.memory.total", :last_value, %{}, last: {2, :hour}) 132 | ``` 133 | 134 | Retrieving summary data can be performed by specifying the type: :summary - however, this returns 135 | value data in the form of a map, which cannot be plotted or csv exported. To reduce the output to 136 | a single metric value, use the form: {:summary, :summary_metric} 137 | 138 | ```elixir 139 | Mobius.Exports.metrics("vm.memory.total", {:summary, :average}, %{}, last: {2, :hour}) 140 | ``` 141 | """ 142 | @spec metrics(Mobius.metric_name(), Mobius.metric_type(), map(), [export_opt()] | keyword()) :: 143 | [Mobius.metric()] 144 | def metrics(metric_name, type, tags, opts \\ []) do 145 | Metrics.export(metric_name, type, tags, opts) 146 | end 147 | 148 | defp get_metrics(metric_name, type, tags, opts) do 149 | filter_metrics_opts = 150 | opts 151 | |> Keyword.put_new(:mobius_instance, :mobius) 152 | |> Keyword.take([:metic_name, :type, :tags, :mobius_instance, :from, :to, :last]) 153 | 154 | metrics(metric_name, type, tags, filter_metrics_opts) 155 | end 156 | 157 | defp build_exporter_opts(metric_name, type, tags, opts) do 158 | opts 159 | |> Keyword.put_new(:metric_name, metric_name) 160 | |> Keyword.put_new(:type, type) 161 | |> Keyword.put_new(:tags, Map.keys(tags)) 162 | end 163 | 164 | @doc """ 165 | Plot the metric name to the screen 166 | 167 | This takes the same arguments as for filter_metrics, eg: 168 | 169 | If there are tags for the metric you can pass those in the second argument: 170 | 171 | ```elixir 172 | Mobius.Exports.plot("vm.memory.total", :last_value, %{some: :tag}) 173 | ``` 174 | 175 | By default the plot will display the last 3 minutes of metric history. 176 | 177 | However, you can pass the `:from` and `:to` options to look at a specific 178 | range of time. 179 | 180 | ```elixir 181 | Mobius.Exports.plot("vm.memory.total", :last_value, %{}, from: 1630619212, to: 1630619219) 182 | ``` 183 | 184 | You can also plot data over the last `x` amount of time. Where x is an 185 | integer. When there is no `time_unit()` provided the unit is assumed to be 186 | `:second`. 187 | 188 | Plotting data over the last 30 seconds: 189 | 190 | ```elixir 191 | Mobius.Export.plot("vm.memory.total", :last_value, %{}, last: 30) 192 | ``` 193 | 194 | Plotting data over the last 2 hours: 195 | 196 | ```elixir 197 | Mobius.Export.plot("vm.memory.total", :last_value, %{}, last: {2, :hour}) 198 | ``` 199 | 200 | Retrieving summary data can be performed by specifying type of the form: 201 | `{:summary, :summary_metric}` 202 | 203 | ```elixir 204 | Mobius.Exports.metrics("vm.memory.total", {:summary, :average}, %{}, last: {2, :hour}) 205 | ``` 206 | """ 207 | @spec plot(Mobius.metric_name(), export_metric_type(), map(), [export_opt()]) :: 208 | :ok | {:error, UnsupportedMetricError.t()} 209 | def plot(metric_name, type, tags \\ %{}, opts \\ []) 210 | 211 | def plot(_metric_name, :summary, _tags, _opts) do 212 | {:error, UnsupportedMetricError.exception(metric_type: :summary)} 213 | end 214 | 215 | def plot(metric_name, type, tags, opts) do 216 | series = series(metric_name, type, tags, opts) 217 | 218 | case Asciichart.plot(series, height: 12) do 219 | {:ok, plot} -> 220 | chart = [ 221 | "\t\t", 222 | IO.ANSI.yellow(), 223 | "Metric Name: ", 224 | metric_name, 225 | IO.ANSI.reset(), 226 | ", ", 227 | IO.ANSI.cyan(), 228 | "Tags: #{inspect(tags)}", 229 | IO.ANSI.reset(), 230 | "\n\n", 231 | plot 232 | ] 233 | 234 | IO.puts(chart) 235 | 236 | error -> 237 | error 238 | end 239 | end 240 | 241 | @type mfb_export_opt() :: {:out_dir, Path.t()} | export_opt() 242 | 243 | @doc """ 244 | Export all metrics in the Mobius Binary Format (MBF) 245 | 246 | This is mostly useful when you want to share metric data with different 247 | networked services. 248 | 249 | The binary format is `<>` 250 | 251 | The first byte is the version number of the following metric data. Currently, 252 | the version number is `1`. 253 | 254 | The metric data binary is the type of `[Mobius.metric()]` encoded in Binary 255 | ERlang Term format (BERT) and compressed (using Zlib compression). 256 | 257 | Optionally, `to_mbf/1` can write the binary to a file using the `:out_dir` 258 | option. 259 | 260 | ```elixir 261 | Mobius.Exports.to_mbf(out_dir: "/my/dir") 262 | ``` 263 | 264 | The generated file is returned as `{:ok, filename}`. The format of the 265 | file name is `YYYYMMDDHHMMSS-metrics.mbf`. 266 | 267 | See `Mobius.Exports.parse_mbf/1` to parse a binary in MBF. 268 | """ 269 | @spec mbf([mfb_export_opt()]) :: binary() | {:ok, Path.t()} | {:error, Mobius.FileError.t()} 270 | def mbf(opts \\ []) do 271 | mobius_instance = opts[:mobius_instance] || :mobius 272 | 273 | mobius_instance 274 | |> Mobius.Scraper.all() 275 | |> Enum.reject(fn metric -> metric.type == :summary end) 276 | |> MobiusBinaryFormat.to_iodata() 277 | |> maybe_write_file(opts) 278 | end 279 | 280 | defp maybe_write_file(iodata, opts) do 281 | case opts[:out_dir] do 282 | nil -> 283 | IO.iodata_to_binary(iodata) 284 | 285 | out_dir -> 286 | file_name = gen_mbf_file_name() 287 | out_file = Path.join(out_dir, file_name) 288 | write_file(out_file, iodata) 289 | end 290 | end 291 | 292 | defp write_file(file, iodata) do 293 | case File.write(file, iodata) do 294 | :ok -> 295 | {:ok, file} 296 | 297 | {:error, reason} -> 298 | {:error, Mobius.FileError.exception(reason: reason, file: file, operation: "write")} 299 | end 300 | end 301 | 302 | defp gen_mbf_file_name() do 303 | "#{file_timestamp()}-metrics.mbf" 304 | end 305 | 306 | defp file_timestamp() do 307 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 308 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 309 | end 310 | 311 | defp pad(i) when i < 10, do: <> 312 | defp pad(i), do: to_string(i) 313 | 314 | @doc """ 315 | Parse the mobius binary format into a list of metrics 316 | """ 317 | @spec parse_mbf(binary()) :: 318 | {:ok, [Mobius.metric()]} | {:error, Mobius.Exports.MBFParseError.t()} 319 | def parse_mbf(binary) do 320 | MobiusBinaryFormat.parse(binary) 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /lib/mobius/exports/csv.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exports.CSV do 2 | @moduledoc false 3 | 4 | @type export_opt() :: 5 | {:metric_name, binary()} 6 | | {:tags, [atom()]} 7 | | {:type, Mobius.metric_type()} 8 | | Mobius.Exports.csv_export_opt() 9 | 10 | @doc """ 11 | Export metrics to a CSV 12 | """ 13 | @spec export_metrics([Mobius.metric()], [export_opt()]) :: :ok | String.t() 14 | def export_metrics(metrics, opts \\ []) do 15 | tag_names = Keyword.fetch!(opts, :tags) 16 | metric_name = Keyword.fetch!(opts, :metric_name) 17 | 18 | headers = make_csv_headers(tag_names, opts) 19 | rows = format_metrics_as_csv(metrics, metric_name, tag_names) 20 | 21 | write_csv([headers | rows], opts) 22 | end 23 | 24 | defp make_csv_headers(extra_tag_headers, opts) do 25 | if opts[:headers] == false do 26 | [] 27 | else 28 | base_headers = ["timestamp", "name", "type", "value"] 29 | 30 | Enum.reduce(extra_tag_headers, base_headers, fn extra_header, headers -> 31 | headers ++ [Atom.to_string(extra_header)] 32 | end) 33 | end 34 | end 35 | 36 | defp format_metrics_as_csv(rows, metric_name, tag_names) do 37 | Enum.map(rows, fn row -> 38 | tag_values = for tag_name <- tag_names, do: "#{Map.get(row.tags, tag_name, "")}" 39 | 40 | data_row = 41 | [ 42 | "#{row.timestamp}", 43 | "#{metric_name}", 44 | "#{row.type}", 45 | "#{row.value}" 46 | ] ++ 47 | tag_values 48 | 49 | data_row 50 | end) 51 | end 52 | 53 | defp write_csv(csv_content, opts) do 54 | case opts[:iodevice] do 55 | nil -> 56 | {:ok, 57 | csv_content 58 | |> Enum.map_join("\n", &Enum.join(&1, ",")) 59 | |> String.trim("\n")} 60 | 61 | device -> 62 | Enum.each(csv_content, fn row -> IO.write(device, [Enum.intersperse(row, ","), "\n"]) end) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/mobius/exports/metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exports.Metrics do 2 | @moduledoc false 3 | 4 | # Module for exporting metrics 5 | 6 | alias Mobius.{Exports, Scraper, Summary} 7 | 8 | @doc """ 9 | Export metrics 10 | """ 11 | @spec export(binary(), Mobius.metric_type(), map(), [Exports.export_opt()]) :: [Mobius.metric()] 12 | def export(metric_name, type, tags, opts \\ []) do 13 | mobius_instance = opts[:mobius_instance] || :mobius 14 | 15 | start_t = System.monotonic_time() 16 | prefix = [:mobius, :export, :metrics] 17 | 18 | scraper_opts = query_opts(opts) 19 | 20 | # Notify telemetry we are starting query 21 | :telemetry.execute(prefix ++ [:start], %{system_time: System.system_time()}, %{ 22 | mobius_instance: mobius_instance, 23 | metric_name: metric_name, 24 | tags: tags, 25 | type: type, 26 | opts: scraper_opts 27 | }) 28 | 29 | rows = 30 | Scraper.all(mobius_instance, scraper_opts) 31 | |> filter_metrics_for_metric(metric_name, type, tags) 32 | 33 | # Notify telemetry we finished query 34 | duration = System.monotonic_time() - start_t 35 | 36 | :telemetry.execute(prefix ++ [:stop], %{duration: duration}, %{ 37 | mobius_instance: mobius_instance, 38 | metric_name: metric_name, 39 | tags: tags, 40 | opts: scraper_opts 41 | }) 42 | 43 | rows 44 | end 45 | 46 | defp filter_metrics_for_metric(metrics, metric_name, :summary, tags) do 47 | do_filter_metrics_for_metric(metrics, metric_name, :summary, tags) 48 | |> Enum.map(fn metric -> 49 | %{metric | value: metric.value |> Summary.calculate()} 50 | end) 51 | end 52 | 53 | defp filter_metrics_for_metric(metrics, metric_name, {:summary, summary_metric}, tags) do 54 | do_filter_metrics_for_metric(metrics, metric_name, :summary, tags) 55 | |> Enum.map(fn metric -> 56 | %{metric | value: metric.value |> Summary.calculate() |> Map.get(summary_metric)} 57 | end) 58 | end 59 | 60 | defp filter_metrics_for_metric(metrics, metric_name, type, tags) do 61 | do_filter_metrics_for_metric(metrics, metric_name, type, tags) 62 | end 63 | 64 | defp do_filter_metrics_for_metric(metrics, metric_name, type, tags) do 65 | Enum.filter(metrics, fn metric -> 66 | metric_name == metric.name && match?(^tags, metric.tags) && type == metric.type 67 | end) 68 | end 69 | 70 | defp query_opts(opts) do 71 | if opts[:from] do 72 | Keyword.take(opts, [:from, :to]) 73 | else 74 | last_ts(opts) 75 | end 76 | end 77 | 78 | defp last_ts(opts) do 79 | now = System.system_time(:second) 80 | 81 | ts = 82 | case opts[:last] do 83 | nil -> 84 | now - 180 85 | 86 | {offset, unit} -> 87 | now - offset * get_unit_offset(unit) 88 | 89 | offset -> 90 | now - offset 91 | end 92 | 93 | [from: ts] 94 | end 95 | 96 | defp get_unit_offset(:second), do: 1 97 | defp get_unit_offset(:minute), do: 60 98 | defp get_unit_offset(:hour), do: 3600 99 | defp get_unit_offset(:day), do: 86400 100 | end 101 | -------------------------------------------------------------------------------- /lib/mobius/exports/mobius_binary_format.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exports.MobiusBinaryFormat do 2 | @moduledoc false 3 | 4 | alias Mobius.Exports.MBFParseError 5 | 6 | @format_version 1 7 | 8 | @doc """ 9 | Turn a list of all metrics in the mobius binary format 10 | """ 11 | @spec to_iodata([Mobius.metric()]) :: iodata() 12 | def to_iodata(metrics) do 13 | [@format_version, :erlang.term_to_binary(metrics, [:compressed])] 14 | end 15 | 16 | @doc """ 17 | Parse the given binary 18 | """ 19 | @spec parse(binary()) :: {:ok, [Mobius.metric()]} | {:error, Mobius.Exports.MBFParseError.t()} 20 | def parse(<<@format_version, metrics_bin::binary>>) do 21 | metrics = :erlang.binary_to_term(metrics_bin) 22 | 23 | if validate_metrics(metrics) do 24 | {:ok, metrics} 25 | else 26 | {:error, MBFParseError.exception(:invalid_format)} 27 | end 28 | rescue 29 | ArgumentError -> 30 | {:error, MBFParseError.exception(:corrupt)} 31 | end 32 | 33 | def parse(_other) do 34 | {:error, MBFParseError.exception(:invalid_format)} 35 | end 36 | 37 | defp validate_metrics(metrics) when is_list(metrics) do 38 | Enum.all?(metrics, fn metric -> 39 | Enum.all?([:name, :tags, :timestamp, :type, :value], &Map.has_key?(metric, &1)) and 40 | is_binary(metric.name) and 41 | is_integer(metric.value) and is_map(metric.tags) and 42 | is_integer(metric.timestamp) and valid_type?(metric.type) 43 | end) 44 | end 45 | 46 | defp validate_metrics(_metrics), do: false 47 | 48 | defp valid_type?(type) do 49 | type in [:last_value, :counter, :sum, :summary] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/mobius/metrics_table.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.MetricsTable do 2 | @moduledoc false 3 | 4 | # Table for tracking current state of metrics 5 | 6 | # MetricTable object structure 7 | # {{normalize_metric_name, metric_type, metadata}, value} 8 | 9 | @typedoc """ 10 | A single entry of a metric in the metric table 11 | """ 12 | @type metric_entry() :: 13 | {Mobius.metric_name(), Mobius.metric_type(), integer(), map()} 14 | 15 | require Logger 16 | 17 | alias Mobius.Summary 18 | alias Telemetry.Metrics 19 | 20 | @doc """ 21 | Initialize the metrics table 22 | """ 23 | @spec init([Mobius.arg()]) :: Mobius.instance() 24 | def init(args) do 25 | table_name = args[:mobius_instance] 26 | 27 | case read_table_from_file(args) do 28 | {:ok, table} -> 29 | table 30 | 31 | {:error, :enoent} -> 32 | # Metrics save file doesn't (yet) exist 33 | :ets.new(table_name, [:named_table, :public, :set]) 34 | 35 | {:error, reason} -> 36 | Logger.warning("[Mobius] Could not recover metrics from file because #{inspect(reason)}") 37 | :ets.new(table_name, [:named_table, :public, :set]) 38 | end 39 | end 40 | 41 | defp read_table_from_file(args) do 42 | path = Path.join(args[:persistence_dir], "metrics_table") 43 | 44 | if File.exists?(path) do 45 | :ets.file2tab(String.to_charlist(path)) 46 | else 47 | {:error, :enoent} 48 | end 49 | end 50 | 51 | defp make_key(name, type, meta), do: {name, type, meta} 52 | 53 | @doc """ 54 | Save the ets table to a file 55 | """ 56 | @spec save(Mobius.instance(), Path.t()) :: :ok | {:error, reason :: term()} 57 | def save(instance, persistence_dir) do 58 | file = String.to_charlist("#{persistence_dir}/metrics_table") 59 | 60 | :ets.tab2file(instance, file) 61 | end 62 | 63 | @doc """ 64 | Put the metric information into the metric table 65 | """ 66 | @spec put( 67 | Mobius.instance(), 68 | Metrics.normalized_metric_name(), 69 | Mobius.metric_type(), 70 | integer(), 71 | map() 72 | ) :: 73 | :ok 74 | def put(table, event_name, type, value, meta \\ %{}) 75 | 76 | def put(table, event_name, :counter, _value, meta) do 77 | key = make_key(event_name, :counter, meta) 78 | 79 | put_counter_type(table, key, 1) 80 | 81 | :ok 82 | end 83 | 84 | def put(table, event_name, :last_value, value, meta) do 85 | key = make_key(event_name, :last_value, meta) 86 | 87 | :ets.insert(table, {key, value}) 88 | 89 | :ok 90 | end 91 | 92 | def put(table, metric_name, :sum, value, meta) do 93 | key = make_key(metric_name, :sum, meta) 94 | 95 | put_counter_type(table, key, value) 96 | 97 | :ok 98 | end 99 | 100 | def put(table, metric_name, :summary, value, meta) do 101 | key = make_key(metric_name, :summary, meta) 102 | 103 | summary = 104 | case :ets.lookup(table, key) do 105 | [{^key, last_summary}] -> Summary.update(last_summary, value) 106 | [] -> Summary.new(value) 107 | end 108 | 109 | :ets.insert(table, {key, summary}) 110 | 111 | :ok 112 | end 113 | 114 | defp put_counter_type(table, key, incr_value) do 115 | position = 2 116 | 117 | update_spec = {position, incr_value} 118 | # the default value to add the increment value to if this has not been set 119 | # yet 120 | default_spec = {position, 0} 121 | 122 | :ets.update_counter(table, key, update_spec, default_spec) 123 | end 124 | 125 | @doc """ 126 | Remove a metric from the metric table 127 | """ 128 | @spec remove(Mobius.instance(), Metrics.normalized_metric_name(), Mobius.metric_type(), map()) :: 129 | :ok 130 | def remove(table, metric_name, type, meta \\ %{}) do 131 | key = make_key(metric_name, type, meta) 132 | 133 | true = :ets.delete(table, key) 134 | 135 | :ok 136 | end 137 | 138 | @doc """ 139 | Increment a counter metric 140 | """ 141 | @spec inc_counter(Mobius.instance(), Metrics.normalized_metric_name(), map()) :: :ok 142 | def inc_counter(table, event_name, meta \\ %{}) do 143 | put(table, event_name, :counter, 1, meta) 144 | end 145 | 146 | @doc """ 147 | Update a sum metric type 148 | """ 149 | @spec update_sum(Mobius.instance(), Metrics.normalized_metric_name(), integer(), map()) :: :ok 150 | def update_sum(table, metric_name, value, meta \\ %{}) do 151 | put(table, metric_name, :sum, value, meta) 152 | end 153 | 154 | @doc """ 155 | Get all entries in the table 156 | """ 157 | @spec get_entries(Mobius.instance()) :: [metric_entry()] 158 | def get_entries(table) do 159 | ms = [ 160 | { 161 | {{:"$1", :"$2", :"$3"}, :"$4"}, 162 | [], 163 | [{{:"$1", :"$2", :"$4", :"$3"}}] 164 | } 165 | ] 166 | 167 | table 168 | |> :ets.select(ms) 169 | |> Enum.map(fn {name, type, value, tags} -> 170 | {normalized_name_to_string(name), type, value, tags} 171 | end) 172 | end 173 | 174 | @doc """ 175 | Get metrics by event name 176 | """ 177 | @spec get_entries_by_metric_name(Mobius.instance(), Mobius.metric_name()) :: [metric_entry()] 178 | def get_entries_by_metric_name(table, metric_name) do 179 | normalized_name = 180 | metric_name 181 | |> String.split(".", trim: true) 182 | |> Enum.map(&String.to_existing_atom/1) 183 | 184 | ms = [ 185 | {{{:"$1", :"$2", :"$3"}, :"$4"}, [{:==, :"$1", normalized_name}], 186 | [{{:"$1", :"$2", :"$4", :"$3"}}]} 187 | ] 188 | 189 | table 190 | |> :ets.select(ms) 191 | |> Enum.map(fn {name, type, value, tags} -> 192 | {normalized_name_to_string(name), type, value, tags} 193 | end) 194 | end 195 | 196 | defp normalized_name_to_string(normalized_name) do 197 | Enum.join(normalized_name, ".") 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/mobius/metrics_table/monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.MetricsTable.Monitor do 2 | @moduledoc false 3 | 4 | # module to save the metrics table to disk when shutting down Mobius 5 | 6 | use GenServer 7 | 8 | alias Mobius.MetricsTable 9 | 10 | @spec start_link([Mobius.arg()]) :: GenServer.on_start() 11 | def start_link(args) do 12 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 13 | end 14 | 15 | defp name(instance) do 16 | Module.concat(__MODULE__, instance) 17 | end 18 | 19 | @doc """ 20 | Persist the metrics to disk 21 | """ 22 | @spec save(Mobius.instance()) :: :ok | {:error, reason :: term()} 23 | def save(instance), do: GenServer.call(name(instance), :save) 24 | 25 | @impl GenServer 26 | def init(args) do 27 | Process.flag(:trap_exit, true) 28 | 29 | state = 30 | args 31 | |> Keyword.take([:mobius_instance, :persistence_dir]) 32 | |> Enum.into(%{}) 33 | 34 | {:ok, state} 35 | end 36 | 37 | @impl GenServer 38 | def handle_call(:save, _from, state) do 39 | {:reply, save_to_persistence(state), state} 40 | end 41 | 42 | @impl GenServer 43 | def terminate(_reason, state) do 44 | save_to_persistence(state) 45 | end 46 | 47 | # Write our ETS table to persistent storage 48 | defp save_to_persistence(state) do 49 | MetricsTable.save(state.mobius_instance, state.persistence_dir) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/mobius/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Registry do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | alias Mobius.MetricsTable 8 | alias Telemetry.Metrics 9 | 10 | @typedoc """ 11 | Arguments to start the registry server 12 | 13 | * `:metrics` - which metrics you want Mobius to attach a handler for 14 | * `:mobius_instance` - the Mobius instance 15 | """ 16 | @type arg() :: {:metrics, [Metrics.t()]} | {:mobius_instance, Mobius.instance()} 17 | 18 | @doc """ 19 | Start the registry server 20 | """ 21 | @spec start_link([arg()]) :: GenServer.on_start() 22 | def start_link(args) do 23 | args = 24 | args 25 | |> Keyword.put_new(:events, []) 26 | |> Keyword.put_new(:metrics, []) 27 | 28 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 29 | end 30 | 31 | defp name(instance) do 32 | Module.concat(__MODULE__, instance) 33 | end 34 | 35 | @doc """ 36 | Get which metrics Mobius is tracking 37 | """ 38 | @spec metrics(Mobius.instance()) :: [Metrics.t()] 39 | def metrics(instance) do 40 | GenServer.call(name(instance), :metrics) 41 | end 42 | 43 | @impl GenServer 44 | def init(args) do 45 | registered = register_metrics(args) 46 | _ = register_events(args) 47 | 48 | {:ok, 49 | %{ 50 | registered: registered, 51 | metrics: Keyword.fetch!(args, :metrics), 52 | table: args[:mobius_instance] 53 | }, {:continue, :update_metrics_table}} 54 | end 55 | 56 | defp register_metrics(args) do 57 | for {event, metrics} <- Enum.group_by(args[:metrics], & &1.event_name) do 58 | name = [:metric | event] 59 | id = {__MODULE__, name, self()} 60 | 61 | _ = 62 | :telemetry.attach(id, event, &Mobius.Events.handle_metrics/4, %{ 63 | table: args[:mobius_instance], 64 | metrics: metrics 65 | }) 66 | 67 | id 68 | end 69 | end 70 | 71 | defp register_events(args) do 72 | events = args[:events] || [] 73 | 74 | for event <- events do 75 | {event, event_opts} = get_event_and_opts(event) 76 | id = {__MODULE__, event, self()} 77 | 78 | _ = 79 | :telemetry.attach(id, event, &Mobius.Events.handle_event/4, %{ 80 | table: args[:mobius_instance], 81 | event_opts: event_opts, 82 | session: args[:session] 83 | }) 84 | 85 | id 86 | end 87 | end 88 | 89 | defp get_event_and_opts({event, opts}), do: {parse_event_name(event), opts} 90 | defp get_event_and_opts(event), do: {parse_event_name(event), []} 91 | 92 | defp parse_event_name(event) do 93 | event 94 | |> String.split(".", trim: true) 95 | |> Enum.map(&String.to_atom/1) 96 | end 97 | 98 | @impl GenServer 99 | def handle_call(:metrics, _from, state) do 100 | {:reply, state.metrics, state} 101 | end 102 | 103 | @impl GenServer 104 | def handle_continue(:update_metrics_table, state) do 105 | state.table 106 | |> MetricsTable.get_entries() 107 | |> Enum.each(&maybe_remove_entry(&1, state)) 108 | 109 | {:noreply, state} 110 | end 111 | 112 | defp maybe_remove_entry({metric_name, metric_type, _value, meta}, state) do 113 | metric_specs = Enum.map(state.metrics, &{&1.name, metric_as_type(&1), &1.tags}) 114 | entry_spec = {metric_name, metric_type, Map.keys(meta)} 115 | 116 | if !Enum.member?(metric_specs, entry_spec) do 117 | MetricsTable.remove(state.table, metric_name, metric_type, meta) 118 | end 119 | end 120 | 121 | defp metric_as_type(%Metrics.Counter{}), do: :counter 122 | defp metric_as_type(%Metrics.LastValue{}), do: :last_value 123 | defp metric_as_type(%Metrics.Sum{}), do: :sum 124 | defp metric_as_type(%Metrics.Summary{}), do: :summary 125 | end 126 | -------------------------------------------------------------------------------- /lib/mobius/report_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.ReportServer do 2 | @moduledoc false 3 | 4 | # server for building reports 5 | 6 | # Right now we will put this in a singleton that handles both metrics and 7 | # events for convenience, but if needed we can refactor them into separate 8 | # servers. 9 | 10 | use GenServer 11 | 12 | alias Mobius.{Event, EventLog, Scraper} 13 | 14 | def start_link(args) do 15 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 16 | end 17 | 18 | defp name(instance) do 19 | Module.concat(__MODULE__, instance) 20 | end 21 | 22 | @doc """ 23 | Get the latest events 24 | """ 25 | @spec get_latest_events(Mobius.instance()) :: [Event.t()] 26 | def get_latest_events(instance) do 27 | GenServer.call(name(instance), :get_latest_events) 28 | end 29 | 30 | def get_latest_metrics(instance) do 31 | GenServer.call(name(instance), :get_latest_metrics) 32 | end 33 | 34 | @impl GenServer 35 | def init(args) do 36 | {:ok, %{instance: args[:mobius_instance], events_next_start: nil, metrics_next_start: nil}} 37 | end 38 | 39 | @impl GenServer 40 | def handle_call(:get_latest_events, _from, state) do 41 | {from, to} = get_query_window(state, :events) 42 | 43 | events = EventLog.list(instance: state.instance, from: from, to: to) 44 | 45 | {:reply, events, %{state | events_next_start: to + 1}} 46 | end 47 | 48 | def handle_call(:get_latest_metrics, _from, state) do 49 | {from, to} = get_query_window(state, :metrics) 50 | 51 | metrics = Scraper.all(state.instance, from: from, to: to) 52 | 53 | {:reply, metrics, %{state | metrics_next_start: to + 1}} 54 | end 55 | 56 | defp get_query_window(%{events_next_start: nil}, :events) do 57 | {0, now()} 58 | end 59 | 60 | defp get_query_window(state, :events) do 61 | {state.events_next_start, now()} 62 | end 63 | 64 | defp get_query_window(%{metrics_next_start: nil}, :metrics) do 65 | {0, now()} 66 | end 67 | 68 | defp get_query_window(state, :metrics) do 69 | {state.metrics_next_start, now()} 70 | end 71 | 72 | defp now() do 73 | System.system_time(:second) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/mobius/rrd.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.RRD do 2 | @moduledoc """ 3 | A round robin database for Mobius 4 | 5 | This is the RRD used by Mobius to store historical metric data. 6 | 7 | A round robin database (RRD) is a data store that has a circular buffer to store 8 | information. As time moves forward the older data points get overwritten by 9 | newer data points. This type of data storage is useful for a consistent memory 10 | footprint for time series data. 11 | 12 | The `Mobius.RRD` implementation provides four time resolutions. These are: seconds, 13 | minutes, hours and days. Each resolution can be configured to allow for as 14 | many single data points as you see fit. For example, if you want to store three 15 | days of data at an hour resolution you can configure the RRD like so: 16 | 17 | ```elixir 18 | RRD.new(hours: 72) 19 | ``` 20 | 21 | The above will configure the hour resolution to store 72 hours worth of data points 22 | in the hour archive. 23 | 24 | The default resolutions are: 25 | 26 | * 60 days (each day for about 2 months) 27 | * 48 hours (each hour for 2 days) 28 | * 120 minutes (each minute for 2 hours) 29 | * 120 seconds (each second for 2 minutes) 30 | 31 | For more information about round robin databases, RRD tool is a great resource 32 | to study. 33 | """ 34 | 35 | @serialization_version 2 36 | 37 | require Logger 38 | 39 | @opaque t() :: %{ 40 | day: CircularBuffer.t(), 41 | hour: CircularBuffer.t(), 42 | minute: CircularBuffer.t(), 43 | second: CircularBuffer.t(), 44 | day_next: integer(), 45 | hour_next: integer(), 46 | minute_next: integer(), 47 | second_next: integer() 48 | } 49 | 50 | @typedoc """ 51 | Resolution name 52 | """ 53 | @type resolution() :: :seconds | :minutes | :hours | :days 54 | 55 | @typedoc """ 56 | Options for the RRD 57 | 58 | For resolution options you specify which resolution and the max number of 59 | metric data to keep for that resolution. 60 | 61 | For example, if the RRD were to track seconds up to five minutes it would need 62 | to track `300` seconds. Also, if the same RRD wanted to track day resolution 63 | for a year, it would need to contain `365` days. 64 | 65 | ```elixir 66 | Mobius.RRD.new(seconds: 300, days: 365) 67 | ``` 68 | """ 69 | @type create_opt() :: {resolution(), non_neg_integer()} 70 | 71 | @doc """ 72 | Create a new RRD 73 | 74 | The default resolution values are: 75 | 76 | * 60 days (each day for about 2 months) 77 | * 48 hours (each hour for 2 days) 78 | * 120 minutes (each minute for 2 hours) 79 | * 120 seconds (each second for 2 minutes) 80 | """ 81 | @spec new([create_opt()]) :: t() 82 | def new(opts \\ []) do 83 | days = opts[:days] || 60 84 | hours = opts[:hours] || 48 85 | minutes = opts[:minutes] || 120 86 | seconds = opts[:seconds] || 120 87 | 88 | %{ 89 | day: CircularBuffer.new(days), 90 | hour: CircularBuffer.new(hours), 91 | minute: CircularBuffer.new(minutes), 92 | second: CircularBuffer.new(seconds), 93 | day_next: 0, 94 | hour_next: 0, 95 | minute_next: 0, 96 | second_next: 0 97 | } 98 | end 99 | 100 | @doc """ 101 | Insert an item for the specified time 102 | """ 103 | @spec insert(t(), integer(), [Mobius.metric()]) :: t() 104 | def insert(rrd, ts, item) do 105 | value = {ts, item} 106 | 107 | cond do 108 | ts >= rrd.day_next -> 109 | %{ 110 | rrd 111 | | day: CircularBuffer.insert(rrd.day, value), 112 | day_next: next(ts, 86400), 113 | hour_next: next(ts, 3600), 114 | minute_next: next(ts, 60), 115 | second_next: ts + 1 116 | } 117 | 118 | ts >= rrd.hour_next -> 119 | %{ 120 | rrd 121 | | hour: CircularBuffer.insert(rrd.hour, value), 122 | hour_next: next(ts, 3600), 123 | minute_next: next(ts, 60), 124 | second_next: ts + 1 125 | } 126 | 127 | ts >= rrd.minute_next -> 128 | %{ 129 | rrd 130 | | minute: CircularBuffer.insert(rrd.minute, value), 131 | minute_next: next(ts, 60), 132 | second_next: ts + 1 133 | } 134 | 135 | ts >= rrd.second_next -> 136 | %{ 137 | rrd 138 | | second: CircularBuffer.insert(rrd.second, value), 139 | second_next: ts + 1 140 | } 141 | 142 | true -> 143 | Logger.debug("Dropping scrape #{inspect(item)} at #{inspect(ts)}") 144 | rrd 145 | end 146 | end 147 | 148 | defp next(ts, res) do 149 | (div(ts, res) + 1) * res 150 | end 151 | 152 | @doc """ 153 | Load persisted data back into a TimeLayerBuffer 154 | 155 | The `rrd` that's passed in is expected to be a new one without any entries. 156 | """ 157 | @spec load(t(), binary()) :: {:ok, t()} | {:error, Mobius.DataLoadError.t()} 158 | def load(rrd, <<1, data::binary>>) do 159 | data 160 | |> :erlang.binary_to_term() 161 | |> migrate_data(1) 162 | |> do_load(rrd) 163 | catch 164 | _, _ -> {:error, Mobius.DataLoadError.exception(reason: :corrupt, who: rrd)} 165 | end 166 | 167 | def load(rrd, <<@serialization_version, data::binary>>) do 168 | data 169 | |> :erlang.binary_to_term() 170 | |> do_load(rrd) 171 | catch 172 | _, _ -> {:error, Mobius.DataLoadError.exception(reason: :corrupt, who: rrd)} 173 | end 174 | 175 | def load(rrd, _) do 176 | {:error, Mobius.DataLoadError.exception(reason: :unsupported_version, who: rrd)} 177 | end 178 | 179 | defp do_load(data, rrd) when is_list(data) do 180 | loaded = 181 | Enum.reduce(data, rrd, fn {ts, metrics}, new_rrd -> 182 | insert(new_rrd, ts, metrics) 183 | end) 184 | 185 | {:ok, loaded} 186 | end 187 | 188 | # migrate data from version 1 to current 189 | defp migrate_data(data, 1) do 190 | Enum.map(data, fn {timestamp, metrics} -> 191 | metrics = 192 | Enum.map(metrics, fn {name, type, value, tags} -> 193 | name = Enum.join(name, ".") 194 | %{name: name, type: type, value: value, tags: tags, timestamp: timestamp} 195 | end) 196 | 197 | {timestamp, metrics} 198 | end) 199 | end 200 | 201 | @typedoc """ 202 | Options for saving RRD into a binary 203 | 204 | * `:serialization_version` - the version of serialization format, defaults to 205 | most recent 206 | """ 207 | @type save_opt() :: {:serialization_version, 1 | 2} 208 | 209 | @doc """ 210 | Serialize to an iolist 211 | """ 212 | @spec save(t(), [save_opt()]) :: iolist() 213 | def save(rrd, opts \\ []) do 214 | serialization_version = opts[:serialization_version] || @serialization_version 215 | 216 | [serialization_version, :erlang.term_to_iovec(all(rrd))] 217 | end 218 | 219 | @doc """ 220 | Return all items in order 221 | """ 222 | @spec all(t()) :: [{Mobius.timestamp(), [Mobius.metric()]}] 223 | def all(rrd) do 224 | result = 225 | CircularBuffer.to_list(rrd.day) ++ 226 | CircularBuffer.to_list(rrd.hour) ++ 227 | CircularBuffer.to_list(rrd.minute) ++ CircularBuffer.to_list(rrd.second) 228 | 229 | Enum.sort(result, fn {ts1, _}, {ts2, _} -> ts1 < ts2 end) 230 | end 231 | 232 | @doc """ 233 | Return all items within the specified range 234 | """ 235 | @spec query(t(), from :: integer(), to :: integer()) :: [{integer(), any()}] 236 | def query(rrd, from, to) do 237 | rrd 238 | |> all() 239 | |> Enum.drop_while(fn {ts, _} -> ts < from end) 240 | |> Enum.take_while(fn {ts, _} -> ts <= to end) 241 | end 242 | 243 | @doc """ 244 | Return all items with timestamps equal to or after the specified one 245 | """ 246 | @spec query(t(), from :: integer()) :: [{integer(), any()}] 247 | def query(rrd, from) do 248 | rrd 249 | |> all() 250 | |> Enum.drop_while(fn {ts, _} -> ts < from end) 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /lib/mobius/scraper.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Scraper do 2 | @moduledoc false 3 | 4 | use GenServer 5 | require Logger 6 | 7 | alias Mobius.{MetricsTable, RRD} 8 | 9 | @interval 1_000 10 | 11 | @doc """ 12 | Start the scraper server 13 | """ 14 | @spec start_link([Mobius.arg()]) :: GenServer.on_start() 15 | def start_link(args) do 16 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 17 | end 18 | 19 | defp name(mobius_instance) do 20 | Module.concat(__MODULE__, mobius_instance) 21 | end 22 | 23 | @typedoc """ 24 | Options to pass to the all call 25 | 26 | * `:from` - the unix timestamp, in seconds, to start querying form 27 | * `:to` - the unix timestamp, in seconds, to query to 28 | """ 29 | @type all_opt() :: {:from, integer()} | {:to, integer()} 30 | 31 | @doc """ 32 | Get all the records 33 | """ 34 | @spec all(Mobius.instance(), [all_opt()]) :: [Mobius.metric()] 35 | def all(instance, opts \\ []) do 36 | GenServer.call(name(instance), {:get, opts}) 37 | end 38 | 39 | @doc """ 40 | Persist the metrics to disk 41 | """ 42 | @spec save(Mobius.instance()) :: :ok | {:error, reason :: term()} 43 | def save(instance), do: GenServer.call(name(instance), :save) 44 | 45 | @impl GenServer 46 | def init(args) do 47 | _ = :timer.send_interval(@interval, self(), :scrape) 48 | Process.flag(:trap_exit, true) 49 | 50 | state = 51 | args 52 | |> state_from_args() 53 | |> make_database(args) 54 | 55 | {:ok, state} 56 | end 57 | 58 | defp state_from_args(args) do 59 | args 60 | |> Keyword.take([:mobius_instance, :persistence_dir]) 61 | |> Enum.into(%{}) 62 | end 63 | 64 | defp make_database(state, args) do 65 | rrd = 66 | args[:database] 67 | |> load_data(state) 68 | 69 | Map.put(state, :database, rrd) 70 | end 71 | 72 | defp load_data(database, state) do 73 | with {:ok, contents} <- File.read(file(state)), 74 | {:ok, rrd} <- RRD.load(database, contents) do 75 | rrd 76 | else 77 | {:error, :enoent} -> 78 | database 79 | 80 | {:error, %Mobius.DataLoadError{} = error} -> 81 | Logger.warning(Exception.message(error)) 82 | 83 | database 84 | end 85 | end 86 | 87 | defp file(state) do 88 | Path.join(state.persistence_dir, "history") 89 | end 90 | 91 | defp to_metrics_list(timestamped_metrics) do 92 | Enum.flat_map(timestamped_metrics, fn {_, metrics} -> 93 | metrics 94 | end) 95 | end 96 | 97 | @impl GenServer 98 | def handle_call({:get, opts}, _from, state) do 99 | case Keyword.get(opts, :from) do 100 | nil -> 101 | metrics = 102 | state.database 103 | |> RRD.all() 104 | |> to_metrics_list() 105 | 106 | {:reply, metrics, state} 107 | 108 | from -> 109 | {:reply, query_database(from, state, opts), state} 110 | end 111 | end 112 | 113 | def handle_call(:save, _from, state) do 114 | {:reply, save_to_persistence(state), state} 115 | end 116 | 117 | defp query_database(from, state, opts) do 118 | case opts[:to] do 119 | nil -> 120 | RRD.query(state.database, from) 121 | |> to_metrics_list() 122 | 123 | to -> 124 | RRD.query(state.database, from, to) 125 | |> to_metrics_list() 126 | end 127 | end 128 | 129 | @impl GenServer 130 | def handle_info(:scrape, state) do 131 | case MetricsTable.get_entries(state.mobius_instance) do 132 | [] -> 133 | {:noreply, state} 134 | 135 | scrape -> 136 | ts = System.system_time(:second) 137 | scrape = scrape_to_metrics_list(ts, scrape) 138 | database = RRD.insert(state.database, ts, scrape) 139 | 140 | {:noreply, %{state | database: database}} 141 | end 142 | end 143 | 144 | def handle_info(_message, state) do 145 | {:noreply, state} 146 | end 147 | 148 | @impl GenServer 149 | def terminate(_reason, state) do 150 | save_to_persistence(state) 151 | end 152 | 153 | defp scrape_to_metrics_list(ts, scrape) do 154 | Enum.map(scrape, fn {name, type, value, tags} -> 155 | %{ 156 | timestamp: ts, 157 | name: name, 158 | type: type, 159 | value: value, 160 | tags: tags 161 | } 162 | end) 163 | end 164 | 165 | # Write our database to persistent storage 166 | defp save_to_persistence(state) do 167 | contents = RRD.save(state.database) 168 | 169 | case File.write(file(state), contents) do 170 | :ok -> 171 | :ok 172 | 173 | error -> 174 | Logger.warning("Failed to save metrics history because #{inspect(error)}") 175 | 176 | error 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /lib/mobius/summary.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Summary do 2 | @moduledoc false 3 | 4 | @typedoc """ 5 | Calculated summary statistics 6 | """ 7 | @type t() :: %{min: integer(), max: integer(), average: float(), std_dev: float()} 8 | 9 | @typedoc """ 10 | A data type to store snapshot information about a summary in order 11 | to make calculations on at a later time 12 | """ 13 | @type data() :: %{ 14 | min: integer(), 15 | max: integer(), 16 | accumulated: integer(), 17 | accumulated_sqrd: integer(), 18 | reports: non_neg_integer() 19 | } 20 | 21 | @doc """ 22 | Create a new summary `data()` based off a metric value 23 | """ 24 | @spec new(integer()) :: data() 25 | def new(metric_value) do 26 | %{ 27 | min: metric_value, 28 | max: metric_value, 29 | accumulated: metric_value, 30 | accumulated_sqrd: metric_value * metric_value, 31 | reports: 1 32 | } 33 | end 34 | 35 | @doc """ 36 | Update a summary `data()` with new information based of a metric value 37 | """ 38 | @spec update(data(), integer()) :: data() 39 | def update(summary_data, new_metric_value) do 40 | %{ 41 | min: min(summary_data.min, new_metric_value), 42 | max: max(summary_data.max, new_metric_value), 43 | accumulated: summary_data.accumulated + new_metric_value, 44 | accumulated_sqrd: summary_data.accumulated_sqrd + new_metric_value * new_metric_value, 45 | reports: summary_data.reports + 1 46 | } 47 | end 48 | 49 | @doc """ 50 | Run any calculations in the summary `data()` to produce a summary 51 | """ 52 | @spec calculate(data()) :: t() 53 | def calculate(summary_data) do 54 | %{ 55 | min: summary_data.min, 56 | max: summary_data.max, 57 | average: summary_data.accumulated / summary_data.reports, 58 | std_dev: 59 | std_dev(summary_data.accumulated, summary_data.accumulated_sqrd, summary_data.reports) 60 | } 61 | end 62 | 63 | defp std_dev(_sum, _sum_sqrd, 1), do: 0 64 | 65 | # Naive algorithm. See Wikipedia 66 | defp std_dev(sum, sum_sqrd, n) do 67 | ((sum_sqrd - sum * sum / n) / (n - 1)) 68 | |> :math.sqrt() 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/mobius/time_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mobius.TimeServer do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @doc """ 7 | Start the TimeServer 8 | """ 9 | @spec start_link([Mobius.arg()]) :: GenServer.on_start() 10 | def start_link(args) do 11 | GenServer.start_link(__MODULE__, args, name: name(args[:mobius_instance])) 12 | end 13 | 14 | defp name(instance) do 15 | Module.concat(__MODULE__, instance) 16 | end 17 | 18 | @doc """ 19 | Register a pid to be notified when the clock is synced 20 | """ 21 | @spec register(Mobius.instance(), pid()) :: :ok 22 | def register(instance \\ :mobius, process) do 23 | GenServer.call(name(instance), {:register, process}) 24 | end 25 | 26 | @doc """ 27 | Check if the clock is synchronized 28 | """ 29 | @spec synchronized?(Mobius.instance()) :: boolean() 30 | def synchronized?(instance \\ :mobius) do 31 | GenServer.call(name(instance), :synchronized?) 32 | end 33 | 34 | @impl GenServer 35 | def init(args) do 36 | started_at = System.monotonic_time() 37 | started_sys_time = System.system_time() 38 | session = args[:session] 39 | 40 | state = %{ 41 | started_at: started_at, 42 | clock: nil, 43 | synced?: true, 44 | registered: [], 45 | started_sys_time: started_sys_time, 46 | session: session 47 | } 48 | 49 | case args[:clock] do 50 | nil -> 51 | {:ok, state} 52 | 53 | clock -> 54 | send_check_clock_after(1_000) 55 | {:ok, %{state | clock: clock, synced?: false}} 56 | end 57 | end 58 | 59 | @impl GenServer 60 | def handle_call({:register, pid}, _from, %{clock: nil} = state) do 61 | notify(System.system_time(), 0, [pid]) 62 | 63 | {:reply, :ok, state} 64 | end 65 | 66 | def handle_call({:register, pid}, _from, state) do 67 | if pid in state.registered do 68 | {:reply, :ok, state} 69 | else 70 | registered = [pid | state.registered] 71 | 72 | {:reply, :ok, %{state | registered: registered}} 73 | end 74 | end 75 | 76 | def handle_call(:synchronized?, _from, state) do 77 | {:reply, state.synced?, state} 78 | end 79 | 80 | @impl GenServer 81 | def handle_info(:check_clock, %{synced?: false} = state) do 82 | if state.clock.synchronized?() do 83 | sync_timestamp = System.system_time() 84 | adjustment = sync_timestamp - state.started_sys_time 85 | 86 | :ok = notify(sync_timestamp, adjustment, state.registered) 87 | 88 | {:noreply, %{state | synced?: true}} 89 | else 90 | send_check_clock_after(1_000) 91 | 92 | {:noreply, state} 93 | end 94 | end 95 | 96 | defp notify(sync_timestamp, adjustment, registered) do 97 | for pid <- registered do 98 | send(pid, {__MODULE__, sync_timestamp, adjustment}) 99 | end 100 | 101 | :ok 102 | end 103 | 104 | defp send_check_clock_after(timer) do 105 | Process.send_after(self(), :check_clock, timer) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.6.1" 5 | 6 | def project do 7 | [ 8 | app: :mobius, 9 | version: @version, 10 | elixir: "~> 1.11", 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps(), 14 | dialyzer: dialyzer(), 15 | description: description(), 16 | package: package(), 17 | docs: docs(), 18 | preferred_cli_env: [docs: :docs, "hex.publish": :docs] 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger, :crypto] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:ex_doc, "~> 0.24", only: :docs, runtime: false}, 33 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 34 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 35 | {:telemetry, "~> 0.4.3 or ~> 1.0"}, 36 | {:telemetry_metrics, "~> 0.6 or ~> 1.0"}, 37 | {:circular_buffer, "~> 0.4.0"}, 38 | {:uuid, "~> 1.1"} 39 | ] 40 | end 41 | 42 | defp docs() do 43 | [ 44 | extras: ["README.md", "CHANGELOG.md"], 45 | main: "readme", 46 | source_ref: "v#{@version}", 47 | source_url: "https://github.com/mobius-home/mobius", 48 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 49 | assets: "assets", 50 | logo: "assets/m.png" 51 | ] 52 | end 53 | 54 | defp description do 55 | "Local metrics library" 56 | end 57 | 58 | defp package do 59 | [ 60 | licenses: ["Apache-2.0"], 61 | links: %{"GitHub" => "https://github.com/mobius-home/mobius"} 62 | ] 63 | end 64 | 65 | defp dialyzer() do 66 | [ 67 | flags: [:unmatched_returns, :error_handling], 68 | plt_add_apps: [:eex, :mix] 69 | ] 70 | end 71 | 72 | defp aliases() do 73 | [ 74 | test: ["test --exclude timeout"] 75 | ] 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "circular_buffer": {:hex, :circular_buffer, "0.4.1", "477f370fd8cfe1787b0a1bade6208bbd274b34f1610e41f1180ba756a7679839", [:mix], [], "hexpm", "633ef2e059dde0d7b89bbab13b1da9d04c6685e80e68fbdf41282d4fae746b72"}, 4 | "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, 9 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 10 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 11 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 15 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 16 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 17 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/mobius/asciichart.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.AsciichartTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Mobius.Asciichart 5 | 6 | test "Can generate a chart with nonvarying values" do 7 | # Ensure that we don't blow up when creating a chart with a single row of unvarying values 8 | assert {:ok, plot} = Asciichart.plot([1, 1, 1, 1]) 9 | assert plot == {:ok, "1.00 ┼─── \n "} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/mobius/event_log_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.EventLogTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.{Event, EventLog, EventsServer} 5 | 6 | @tag :tmp_dir 7 | test "gets a list of all the events", %{tmp_dir: tmp_dir} do 8 | gen_events = 9 | for event_name <- ["a.b.c", "one.two.three", "x.y.z"] do 10 | ts = System.system_time(:second) 11 | Event.new("test", event_name, ts - Enum.random(1..30), %{a: 1}, %{test: true}) 12 | end 13 | 14 | load_event_log(:list_all, tmp_dir, gen_events) 15 | 16 | events = EventLog.list(instance: :list_all) 17 | 18 | refute Enum.empty?(events) 19 | 20 | for event <- events do 21 | assert event.name in ["a.b.c", "one.two.three", "x.y.z"] 22 | end 23 | end 24 | 25 | @tag :tmp_dir 26 | test "to binary works (version 1)", %{tmp_dir: tmp_dir} do 27 | events = [ 28 | Event.new("test", "a.b.c", 123_123, %{a: 1}, %{}), 29 | Event.new("test", "d.e.f", 123_124, %{a: 1}, %{}) 30 | ] 31 | 32 | load_event_log(:to_binary_works, tmp_dir, events) 33 | 34 | expected_bin = <<0x01, :erlang.term_to_binary(events)::binary>> 35 | 36 | bin = EventLog.to_binary(instance: :to_binary_works) 37 | 38 | assert bin == expected_bin 39 | end 40 | 41 | test "parses version 1" do 42 | events = [ 43 | Event.new("test", "a.b.c", 123_123, %{a: 1}, %{}), 44 | Event.new("test", "d.e.f", 123_124, %{a: 1}, %{}) 45 | ] 46 | 47 | bin = <<0x01, :erlang.term_to_binary(events)::binary>> 48 | 49 | {:ok, event_log} = EventLog.parse(bin) 50 | 51 | assert event_log == events 52 | end 53 | 54 | defp load_event_log(log_name, dir, events) do 55 | start_supervised!({Mobius, mobius_instance: log_name, persistence_dir: dir}) 56 | 57 | Enum.each(events, fn event -> EventsServer.insert(log_name, event) end) 58 | end 59 | 60 | describe "form and to options" do 61 | @tag :tmp_dir 62 | test "default: all events", %{tmp_dir: tmp_dir} do 63 | events = [ 64 | Event.new("test", "a.b.c", %{a: 1}, %{}), 65 | Event.new("test", "d.e.f", %{a: 1}, %{}) 66 | ] 67 | 68 | load_event_log(:default_from_and_to, tmp_dir, events) 69 | 70 | logged_events = EventLog.list(instance: :default_from_and_to) 71 | 72 | assert logged_events == events 73 | end 74 | 75 | @tag :tmp_dir 76 | test "filter from", %{tmp_dir: tmp_dir} do 77 | events = [ 78 | Event.new("test", "a.b.c", %{a: 1}, %{}, timestamp: 1), 79 | Event.new("test", "d.e.f", %{a: 1}, %{}) 80 | ] 81 | 82 | load_event_log(:filter_event_from, tmp_dir, events) 83 | 84 | logged_events = EventLog.list(instance: :filter_event_from, from: 2) 85 | last_event = List.last(events) 86 | 87 | assert logged_events == [last_event] 88 | end 89 | 90 | @tag :tmp_dir 91 | test "filter with to", %{tmp_dir: tmp_dir} do 92 | events = [ 93 | Event.new("test", "a.b.c", %{a: 1}, %{}, timestamp: 1), 94 | Event.new("test", "d.e.f", %{a: 1}, %{}, timestamp: 50), 95 | Event.new("test", "g.h.i", %{a: 1}, %{}, timestamp: 100) 96 | ] 97 | 98 | load_event_log(:filter_event_to, tmp_dir, events) 99 | 100 | logged_events = EventLog.list(instance: :filter_event_to, to: 50) 101 | 102 | assert logged_events == Enum.take(events, 2) 103 | end 104 | 105 | @tag :tmp_dir 106 | test "provide complete time window", %{tmp_dir: tmp_dir} do 107 | events = [ 108 | Event.new("test", "a.b.c", %{a: 1}, %{}, timestamp: 1), 109 | Event.new("test", "d.e.f", %{a: 1}, %{}, timestamp: 50), 110 | Event.new("test", "g.h.i", %{a: 1}, %{}, timestamp: 55), 111 | Event.new("test", "j.k.l", %{a: 1}, %{}, timestamp: 99), 112 | Event.new("test", "m.n.o", %{a: 1}, %{}, timestamp: 100) 113 | ] 114 | 115 | load_event_log(:filter_event_from_to_window, tmp_dir, events) 116 | 117 | logged_events = EventLog.list(instance: :filter_event_from_to_window, from: 50, to: 99) 118 | 119 | assert logged_events == Enum.drop(events, 1) |> Enum.take(3) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/mobius/events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.EventsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.{Events, MetricsTable} 5 | alias Telemetry.Metrics 6 | 7 | setup do 8 | table = :mobius_test_events 9 | MetricsTable.init(mobius_instance: table, persistence_dir: "/does/not/matter/here") 10 | 11 | {:ok, %{table: table}} 12 | end 13 | 14 | test "handles counter metric", %{table: table} do 15 | name = "events.test.count.me" 16 | 17 | config = %{ 18 | table: table, 19 | metrics: [Metrics.counter("events.test.count.me")] 20 | } 21 | 22 | :ok = Events.handle_metrics([:events, :test, :count], %{}, %{}, config) 23 | 24 | assert [{^name, :counter, 1, %{}}] = MetricsTable.get_entries_by_metric_name(table, name) 25 | end 26 | 27 | test "handles last value metric", %{table: table} do 28 | name = "events.test.last.value" 29 | 30 | config = %{ 31 | table: table, 32 | metrics: [Metrics.last_value("events.test.last.value")] 33 | } 34 | 35 | :ok = Events.handle_metrics([:events, :test, :last, :value], %{value: 1000}, %{}, config) 36 | 37 | assert [{^name, :last_value, 1000, %{}}] = 38 | MetricsTable.get_entries_by_metric_name(table, name) 39 | end 40 | 41 | describe "event handling" do 42 | @tag :tmp_dir 43 | test "basic event", %{tmp_dir: tmp_dir} do 44 | start_supervised!({Mobius, mobius_instance: :basic_event, persistence_dir: tmp_dir}) 45 | 46 | config = %{ 47 | table: :basic_event, 48 | event_opts: [], 49 | session: "test" 50 | } 51 | 52 | :ok = Events.handle_event("a.b.c", %{a: 1}, %{t: 1}, config) 53 | 54 | assert [event] = Mobius.EventLog.list(instance: :basic_event) 55 | 56 | assert event.name == "a.b.c" 57 | assert event.measurements == %{a: 1} 58 | assert event.tags == %{} 59 | end 60 | 61 | @tag :tmp_dir 62 | test "filter for tags", %{tmp_dir: tmp_dir} do 63 | start_supervised!({Mobius, mobius_instance: :filter_for_tags, persistence_dir: tmp_dir}) 64 | 65 | config = %{ 66 | table: :filter_for_tags, 67 | event_opts: [tags: [:t]], 68 | session: "test" 69 | } 70 | 71 | :ok = Events.handle_event("a.b.c", %{a: 1}, %{t: 1, z: 2}, config) 72 | 73 | assert [event] = Mobius.EventLog.list(instance: :filter_for_tags) 74 | 75 | assert event.name == "a.b.c" 76 | assert event.measurements == %{a: 1} 77 | assert event.tags == %{t: 1} 78 | end 79 | 80 | @tag :tmp_dir 81 | test "process measurements", %{tmp_dir: tmp_dir} do 82 | start_supervised!( 83 | {Mobius, mobius_instance: :process_measurements, persistence_dir: tmp_dir} 84 | ) 85 | 86 | config = %{ 87 | table: :process_measurements, 88 | event_opts: [tags: [:t], measurements_values: &event_measurement_processor/1], 89 | session: "test" 90 | } 91 | 92 | :ok = Events.handle_event("a.b.c", %{a: 1, b: 1}, %{t: 1, z: 2}, config) 93 | 94 | assert [event] = Mobius.EventLog.list(instance: :process_measurements) 95 | 96 | assert event.name == "a.b.c" 97 | assert event.measurements == %{a: 2, b: 1} 98 | assert event.tags == %{t: 1} 99 | end 100 | end 101 | 102 | defp event_measurement_processor({:a, n}) do 103 | n + 1 104 | end 105 | 106 | defp event_measurement_processor({_, value}) do 107 | value 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/mobius/exports/csv_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exporters.CSVTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.Exports.CSV 5 | import ExUnit.CaptureIO 6 | 7 | setup do 8 | metrics_no_tags = [ 9 | %{timestamp: 1, type: :last_value, value: 10, tags: %{}}, 10 | %{timestamp: 2, type: :last_value, value: 13, tags: %{}}, 11 | %{timestamp: 4, type: :last_value, value: 15, tags: %{}}, 12 | %{timestamp: 8, type: :last_value, value: 16, tags: %{}} 13 | ] 14 | 15 | metrics_extra_tags = [ 16 | %{timestamp: 1, type: :last_value, value: 10, tags: %{extra: "info"}}, 17 | %{timestamp: 2, type: :last_value, value: 13, tags: %{extra: "info"}}, 18 | %{timestamp: 4, type: :last_value, value: 15, tags: %{extra: "stuff"}}, 19 | %{timestamp: 8, type: :last_value, value: 16, tags: %{extra: "data"}} 20 | ] 21 | 22 | {:ok, metrics_no_tags: metrics_no_tags, metrics_extra_tags: metrics_extra_tags} 23 | end 24 | 25 | test "generates basic string", %{metrics_no_tags: metrics_no_tags} do 26 | {:ok, csv_string} = CSV.export_metrics(metrics_no_tags, tags: [], metric_name: "test") 27 | 28 | expected_string = """ 29 | timestamp,name,type,value 30 | 1,test,last_value,10 31 | 2,test,last_value,13 32 | 4,test,last_value,15 33 | 8,test,last_value,16 34 | """ 35 | 36 | assert csv_string == String.trim(expected_string, "\n") 37 | end 38 | 39 | test "generates CSV with no headers", %{metrics_no_tags: metrics_no_tags} do 40 | {:ok, csv_string} = 41 | CSV.export_metrics(metrics_no_tags, tags: [], metric_name: "test", headers: false) 42 | 43 | expected_string = """ 44 | 1,test,last_value,10 45 | 2,test,last_value,13 46 | 4,test,last_value,15 47 | 8,test,last_value,16 48 | """ 49 | 50 | assert csv_string == String.trim(expected_string, "\n") 51 | end 52 | 53 | test "generates CSV with tags", %{metrics_extra_tags: metrics} do 54 | {:ok, csv_string} = CSV.export_metrics(metrics, tags: [:extra], metric_name: "no.tag.test") 55 | 56 | expected_string = """ 57 | timestamp,name,type,value,extra 58 | 1,no.tag.test,last_value,10,info 59 | 2,no.tag.test,last_value,13,info 60 | 4,no.tag.test,last_value,15,stuff 61 | 8,no.tag.test,last_value,16,data 62 | """ 63 | 64 | assert csv_string == String.trim(expected_string, "\n") 65 | end 66 | 67 | test "print to screen", %{metrics_no_tags: metrics} do 68 | expected_string = """ 69 | timestamp,name,type,value 70 | 1,test,last_value,10 71 | 2,test,last_value,13 72 | 4,test,last_value,15 73 | 8,test,last_value,16 74 | """ 75 | 76 | assert capture_io(fn -> 77 | CSV.export_metrics(metrics, tags: [], metric_name: "test", iodevice: :stdio) 78 | end) == expected_string 79 | end 80 | 81 | @tag :tmp_dir 82 | test "save to file", %{metrics_no_tags: metrics, tmp_dir: tmp_dir} do 83 | tmp_csv = Path.join(tmp_dir, "test.csv") 84 | 85 | {:ok, file} = File.open(tmp_csv, [:write]) 86 | :ok = CSV.export_metrics(metrics, tags: [], metric_name: "test", iodevice: file) 87 | 88 | expected_string = """ 89 | timestamp,name,type,value 90 | 1,test,last_value,10 91 | 2,test,last_value,13 92 | 4,test,last_value,15 93 | 8,test,last_value,16 94 | """ 95 | 96 | assert File.read!(tmp_csv) == expected_string 97 | 98 | File.close(file) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/mobius/exports/mobius_binary_format_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Exports.MobiusBinaryFormatTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.Exports.{MBFParseError, MobiusBinaryFormat} 5 | 6 | test "parsing version 1 Mobius Binary Format" do 7 | metrics = [ 8 | %{name: "test.m", value: 1, timestamp: 1, tags: %{}, type: :counter} 9 | ] 10 | 11 | mbf = 12 | MobiusBinaryFormat.to_iodata(metrics) 13 | |> IO.iodata_to_binary() 14 | 15 | assert {:ok, ^metrics} = MobiusBinaryFormat.parse(mbf) 16 | end 17 | 18 | test "error parsing mbf binary with wrong metric information" do 19 | invalid_binary = <<1, 4>> 20 | error = MBFParseError.exception(:corrupt) 21 | 22 | assert {:error, error} == MobiusBinaryFormat.parse(invalid_binary) 23 | end 24 | 25 | test "error parsing bad metrics" do 26 | error = MBFParseError.exception(:invalid_format) 27 | missing_metric_fields = MobiusBinaryFormat.to_iodata([%{name: ""}]) 28 | 29 | bad_metric_timestamp = 30 | MobiusBinaryFormat.to_iodata([ 31 | %{name: "", timestamp: "", type: :last_value, value: 123, tags: %{}} 32 | ]) 33 | 34 | bad_metric_name = 35 | MobiusBinaryFormat.to_iodata([ 36 | %{name: 123, timestamp: 123, type: :last_value, value: 123, tags: %{}} 37 | ]) 38 | 39 | bad_metric_type = 40 | MobiusBinaryFormat.to_iodata([ 41 | %{name: "", timestamp: 123, type: :another_type, value: 123, tags: %{}} 42 | ]) 43 | 44 | bad_metric_tags = 45 | MobiusBinaryFormat.to_iodata([ 46 | %{name: "", timestamp: 123, type: :last_value, value: 123, tags: []} 47 | ]) 48 | 49 | bad_metric_value = 50 | MobiusBinaryFormat.to_iodata([ 51 | %{name: "", timestamp: 123, type: :last_value, value: "a value", tags: %{}} 52 | ]) 53 | 54 | assert {:error, error} == MobiusBinaryFormat.parse(missing_metric_fields) 55 | assert {:error, error} == MobiusBinaryFormat.parse(bad_metric_timestamp) 56 | assert {:error, error} == MobiusBinaryFormat.parse(bad_metric_name) 57 | assert {:error, error} == MobiusBinaryFormat.parse(bad_metric_type) 58 | assert {:error, error} == MobiusBinaryFormat.parse(bad_metric_value) 59 | assert {:error, error} == MobiusBinaryFormat.parse(bad_metric_tags) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/mobius/exports_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.ExportsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.Exports 5 | alias Mobius.Exports.MobiusBinaryFormat 6 | 7 | @tag :tmp_dir 8 | test "export and parse Mobius Binary Format to binary", %{tmp_dir: tmp_dir} do 9 | metrics = [ 10 | Telemetry.Metrics.last_value("make.mbf.value"), 11 | Telemetry.Metrics.last_value("make.another.value") 12 | ] 13 | 14 | args = 15 | tmp_dir 16 | |> make_args() 17 | |> Keyword.merge(metrics: metrics, mobius_instance: :export_mbf) 18 | 19 | {:ok, _} = start_supervised({Mobius, args}) 20 | execute_telemetry([:make, :mbf], %{value: 100}) 21 | execute_telemetry([:make, :another], %{value: 100}) 22 | 23 | # make sure there's time for the scrapper 24 | Process.sleep(1_000) 25 | 26 | mbf = Exports.mbf(mobius_instance: :export_mbf) 27 | 28 | assert {:ok, metrics} = Exports.parse_mbf(mbf) 29 | 30 | assert is_list(metrics) 31 | assert Enum.all?(metrics, fn m -> m.name in ["make.mbf.value", "make.another.value"] end) 32 | end 33 | 34 | @tag :tmp_dir 35 | test "export version 1 Mobius Binary Format to file", %{tmp_dir: tmp_dir} do 36 | {:ok, _} = start_supervised({Mobius, make_args(tmp_dir)}) 37 | expected_bin = MobiusBinaryFormat.to_iodata([]) |> IO.iodata_to_binary() 38 | 39 | {:ok, file} = Exports.mbf(out_dir: tmp_dir) 40 | 41 | assert File.read!(file) == expected_bin 42 | end 43 | 44 | defp make_args(persistence_dir) do 45 | [ 46 | metrics: [], 47 | persistence_dir: persistence_dir 48 | ] 49 | end 50 | 51 | defp execute_telemetry(event, measurements) do 52 | :telemetry.execute(event, measurements, %{}) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/mobius/metrics/metrics_table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.Metrics.MetricsTableTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.MetricsTable 5 | 6 | setup do 7 | table_name = :metrics_table_test_table 8 | MetricsTable.init(mobius_instance: table_name, persistence_dir: "/does/not/matter/here") 9 | 10 | {:ok, %{table: table_name}} 11 | end 12 | 13 | test "initialize counter on first update", %{table: table} do 14 | :ok = MetricsTable.put(table, [:counter, :event, :hello], :counter, 1) 15 | 16 | result = MetricsTable.get_entries_by_metric_name(table, "counter.event.hello") 17 | 18 | assert result == [{"counter.event.hello", :counter, 1, %{}}] 19 | end 20 | 21 | test "increment counter after first report", %{table: table} do 22 | :ok = MetricsTable.put(table, [:counter, :event, :world], :counter, 1) 23 | :ok = MetricsTable.put(table, [:counter, :event, :world], :counter, 1) 24 | 25 | result = MetricsTable.get_entries_by_metric_name(table, "counter.event.world") 26 | 27 | assert result == [{"counter.event.world", :counter, 2, %{}}] 28 | end 29 | 30 | test "increment counter with inc_counter/3", %{table: table} do 31 | metric_name = "increment.helper.event" 32 | 33 | Enum.each(0..2, fn _ -> 34 | :ok = MetricsTable.inc_counter(table, [:increment, :helper, :event]) 35 | end) 36 | 37 | result = MetricsTable.get_entries_by_metric_name(table, metric_name) 38 | 39 | assert result == [{metric_name, :counter, 3, %{}}] 40 | end 41 | 42 | test "initialize last value on first report", %{table: table} do 43 | metric_name = "last.value.event.one" 44 | 45 | :ok = MetricsTable.put(table, [:last, :value, :event, :one], :last_value, 123) 46 | 47 | result = MetricsTable.get_entries_by_metric_name(table, metric_name) 48 | 49 | assert result == [{metric_name, :last_value, 123, %{}}] 50 | end 51 | 52 | test "update last value after first report", %{table: table} do 53 | metric_name = "last.value.event.two" 54 | 55 | :ok = MetricsTable.put(table, [:last, :value, :event, :two], :last_value, 321) 56 | :ok = MetricsTable.put(table, [:last, :value, :event, :two], :last_value, 765) 57 | 58 | result = MetricsTable.get_entries_by_metric_name(table, metric_name) 59 | 60 | assert result == [{metric_name, :last_value, 765, %{}}] 61 | end 62 | 63 | test "remove a metric from the metric table", %{table: table} do 64 | metric_name = "i.will.be.removed" 65 | :ok = MetricsTable.put(table, [:i, :will, :be, :removed], :last_value, 1000) 66 | 67 | # ensure the metric is saved 68 | assert [{metric_name, :last_value, 1000, %{}}] == 69 | MetricsTable.get_entries_by_metric_name(table, metric_name) 70 | 71 | :ok = MetricsTable.remove(table, [:i, :will, :be, :removed], :last_value) 72 | 73 | # make sure removed 74 | assert [] == MetricsTable.get_entries_by_metric_name(table, metric_name) 75 | end 76 | 77 | test "update a sum of values", %{table: table} do 78 | metric_name = "sum" 79 | :ok = MetricsTable.update_sum(table, [:sum], 100) 80 | 81 | assert [{metric_name, :sum, 100, %{}}] == 82 | MetricsTable.get_entries_by_metric_name(table, metric_name) 83 | 84 | :ok = MetricsTable.update_sum(table, [:sum], 50) 85 | 86 | assert [{metric_name, :sum, 150, %{}}] == 87 | MetricsTable.get_entries_by_metric_name(table, metric_name) 88 | end 89 | 90 | test "handle summary telemetry", %{table: table} do 91 | metric_name = "summary" 92 | :ok = MetricsTable.put(table, [:summary], :summary, 100) 93 | 94 | assert [{^metric_name, :summary, %{accumulated: 100, max: 100, min: 100, reports: 1}, %{}}] = 95 | MetricsTable.get_entries_by_metric_name(table, metric_name) 96 | 97 | :ok = MetricsTable.put(table, [:summary], :summary, 120) 98 | 99 | assert [{^metric_name, :summary, %{accumulated: 220, max: 120, min: 100, reports: 2}, %{}}] = 100 | MetricsTable.get_entries_by_metric_name(table, metric_name) 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/mobius/rrd_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.RRDTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.RRD 5 | 6 | @args [days: 60, hours: 48, minutes: 120, seconds: 120] 7 | 8 | test "create a new one" do 9 | buffer = RRD.new(@args) 10 | assert RRD.all(buffer) == [] 11 | end 12 | 13 | test "insert a scrape" do 14 | buffer = 15 | RRD.new(@args) 16 | |> RRD.insert(1234, :first) 17 | |> RRD.insert(1235, :second) 18 | 19 | assert RRD.all(buffer) == [{1234, :first}, {1235, :second}] 20 | end 21 | 22 | test "query for scrapes in a time range" do 23 | buffer = 24 | RRD.new(@args) 25 | |> RRD.insert(1234, :first) 26 | |> RRD.insert(3000, :second) 27 | 28 | assert RRD.query(buffer, 1000, 2000) == [{1234, :first}] 29 | assert RRD.query(buffer, 1000, 3000) == [{1234, :first}, {3000, :second}] 30 | assert RRD.query(buffer, 2000, 3000) == [{3000, :second}] 31 | assert RRD.query(buffer, 10, 30) == [] 32 | 33 | assert RRD.query(buffer, 1000) == [{1234, :first}, {3000, :second}] 34 | assert RRD.query(buffer, 3000) == [{3000, :second}] 35 | assert RRD.query(buffer, 3001) == [] 36 | end 37 | 38 | describe "serialize and decode" do 39 | test "version 1" do 40 | in_rrd = 41 | RRD.new(@args) 42 | |> RRD.insert(1234, [{[:vm, :memory, :total], :last_value, 123, %{}}]) 43 | |> RRD.insert(3000, [{[:vm, :memory, :total], :last_value, 124, %{}}]) 44 | 45 | expected_rrd = 46 | RRD.new(@args) 47 | |> RRD.insert(1234, [ 48 | %{name: "vm.memory.total", type: :last_value, value: 123, tags: %{}, timestamp: 1234} 49 | ]) 50 | |> RRD.insert(3000, [ 51 | %{name: "vm.memory.total", type: :last_value, value: 124, tags: %{}, timestamp: 3000} 52 | ]) 53 | 54 | in_rrd_binary = RRD.save(in_rrd, serialization_version: 1) |> IO.iodata_to_binary() 55 | assert RRD.load(RRD.new(@args), in_rrd_binary) == {:ok, expected_rrd} 56 | end 57 | 58 | test "version 2" do 59 | rrd = 60 | RRD.new(@args) 61 | |> RRD.insert(1234, [ 62 | %{name: "vm.memory.total", type: :last_value, value: 123, tags: %{}, timestamp: 1234} 63 | ]) 64 | |> RRD.insert(3000, [ 65 | %{name: "vm.memory.total", type: :last_value, value: 124, tags: %{}, timestamp: 3000} 66 | ]) 67 | 68 | rrd_binary = RRD.save(rrd) |> IO.iodata_to_binary() 69 | assert RRD.load(RRD.new(@args), rrd_binary) == {:ok, rrd} 70 | end 71 | end 72 | 73 | test "fails to load corrupt binaries" do 74 | empty_tlb = RRD.new(@args) 75 | 76 | bad_version = <<100, 2, 3, 4>> 77 | 78 | assert RRD.load(empty_tlb, bad_version) == 79 | {:error, Mobius.DataLoadError.exception(reason: :unsupported_version)} 80 | 81 | bad_term = <<1, 2, 3, 4, 5>> 82 | 83 | assert RRD.load(empty_tlb, bad_term) == 84 | {:error, Mobius.DataLoadError.exception(reason: :corrupt)} 85 | 86 | unexpected_term = <<1>> <> :erlang.term_to_binary(:not_a_list) 87 | 88 | assert RRD.load(empty_tlb, unexpected_term) == 89 | {:error, Mobius.DataLoadError.exception(reason: :corrupt)} 90 | 91 | unexpected_term2 = <<1>> <> :erlang.term_to_binary([:not_a_tuple]) 92 | 93 | assert RRD.load(empty_tlb, unexpected_term2) == 94 | {:error, Mobius.DataLoadError.exception(reason: :corrupt)} 95 | 96 | unexpected_term3 = <<1>> <> :erlang.term_to_binary([{:not_a_timestamp, :value}]) 97 | 98 | assert RRD.load(empty_tlb, unexpected_term3) == 99 | {:error, Mobius.DataLoadError.exception(reason: :corrupt)} 100 | end 101 | 102 | test "fill up the all buffers" do 103 | now = 60 * 86400 104 | 105 | # Insert 60 days of records 106 | buffer = 107 | Enum.reduce( 108 | 0..(now - 1), 109 | RRD.new(@args), 110 | &RRD.insert(&2, &1, &1) 111 | ) 112 | 113 | # Last 2 seconds 114 | assert Enum.count(RRD.query(buffer, now - 2)) == 2 115 | 116 | # Last 2 minutes (all 120 second resolution samples) 117 | assert Enum.count(RRD.query(buffer, now - 2 * 60)) == 120 118 | 119 | # Last 3 minutes (3 minute samples and all 120 seconds of samples) 120 | assert Enum.count(RRD.query(buffer, now - 3 * 60)) == 123 121 | 122 | # Last 2 hours (2 hour samples, 118 minute samples, all 120 second samples) 123 | assert Enum.count(RRD.query(buffer, now - 2 * 3600)) == 2 + 118 + 120 124 | 125 | # Last 2 days (2 day samples, 46 hour samples, all 120 minute samples and all 120 second samples) 126 | assert Enum.count(RRD.query(buffer, now - 2 * 86400)) == 2 + 46 + 120 + 120 127 | 128 | # Last 3 days (3 day samples, 48 hour samples, all 120 minute samples and all 120 second samples) 129 | assert Enum.count(RRD.query(buffer, now - 3 * 86400)) == 3 + 48 + 120 + 120 130 | 131 | # Last 60 days 132 | assert Enum.count(RRD.query(buffer, 0)) == 60 + 48 + 120 + 120 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/mobius/summary_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mobius.SummaryTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mobius.Summary 5 | 6 | test "create new summary data from a measurement" do 7 | expected_summary_data = %{ 8 | reports: 1, 9 | accumulated: 100, 10 | accumulated_sqrd: 10000, 11 | min: 100, 12 | max: 100 13 | } 14 | 15 | assert expected_summary_data == Summary.new(100) 16 | end 17 | 18 | test "update one with a new measurement" do 19 | expected_summary_data = %{ 20 | reports: 1, 21 | accumulated: 100, 22 | accumulated_sqrd: 10000, 23 | min: 100, 24 | max: 100 25 | } 26 | 27 | assert expected_summary_data == Summary.new(100) 28 | end 29 | 30 | test "calculate summary from summary data" do 31 | expected_summary = %{min: 100, max: 400, average: 250, std_dev: 212.13203435596427} 32 | 33 | summary_data = 34 | 100 35 | |> Summary.new() 36 | |> Summary.update(400) 37 | 38 | assert expected_summary == Summary.calculate(summary_data) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/mobius_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MobiusTest do 2 | use ExUnit.Case, async: false 3 | import ExUnit.CaptureLog 4 | 5 | @persistence_dir System.tmp_dir!() |> Path.join("mobius_test") 6 | @default_args [ 7 | persistence_dir: @persistence_dir, 8 | metrics: [] 9 | ] 10 | @default_instance_str "mobius" 11 | 12 | setup do 13 | File.rm_rf!(@persistence_dir) 14 | File.mkdir_p(@persistence_dir) 15 | end 16 | 17 | test "starts" do 18 | assert {:ok, _pid} = start_supervised({Mobius, @default_args}) 19 | end 20 | 21 | test "does not crash with a corrupt history file" do 22 | persistence_path = Path.join(@persistence_dir, @default_instance_str) 23 | File.mkdir_p(persistence_path) 24 | File.write!(file(persistence_path), <<>>) 25 | 26 | assert capture_log(fn -> 27 | assert {:ok, _pid} = start_supervised({Mobius, @default_args}) 28 | end) =~ "Unable to load data because of :unsupported_version" 29 | end 30 | 31 | test "can save persistence data" do 32 | persistence_path = Path.join(@persistence_dir, @default_instance_str) 33 | {:ok, _pid} = start_supervised({Mobius, @default_args}) 34 | 35 | assert :ok = Mobius.save(@default_instance_str) 36 | assert File.exists?(Path.join(persistence_path, "history")) 37 | assert File.exists?(Path.join(persistence_path, "metrics_table")) 38 | end 39 | 40 | test "can autosave persistence data" do 41 | persistence_path = Path.join(@persistence_dir, @default_instance_str) 42 | {:ok, _pid} = start_supervised({Mobius, @default_args ++ [autosave_interval: 1]}) 43 | refute File.exists?(Path.join(persistence_path, "history")) 44 | 45 | # Sleep for a bit and check we autosaved in the meantime 46 | Process.sleep(1_100) 47 | assert File.exists?(Path.join(persistence_path, "history")) 48 | assert File.exists?(Path.join(persistence_path, "metrics_table")) 49 | end 50 | 51 | defp file(persistence_dir) do 52 | Path.join(persistence_dir, "history") 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------