├── .gitignore ├── .formatter.exs ├── test ├── test_helper.exs ├── statix │ ├── pool_test.exs │ ├── config_test.exs │ └── overriding_test.exs ├── support │ └── test_server.exs └── statix_test.exs ├── CHANGELOG.md ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── mix.lock ├── lib ├── statix │ ├── packet.ex │ └── conn.ex └── statix.ex └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | /cover 5 | erl_crash.dump 6 | *.ez 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("support/test_server.exs", __DIR__) 2 | 3 | ExUnit.start() 4 | 5 | defmodule Statix.TestCase do 6 | use ExUnit.CaseTemplate 7 | 8 | using options do 9 | port = Keyword.get(options, :port, 8125) 10 | 11 | quote do 12 | setup_all do 13 | {:ok, _} = Statix.TestServer.start_link(unquote(port), __MODULE__.Server) 14 | :ok 15 | end 16 | 17 | setup do 18 | Statix.TestServer.setup(__MODULE__.Server) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | * Fixed prefix building when connect options provided. 4 | 5 | ## v1.4.0 6 | 7 | * Added support for connection pooling that is configurable via the `:pool_size` option. 8 | 9 | ## v1.3.0 10 | 11 | * Added the `c:Statix.connect/1` callback to support runtime configuration. 12 | * Dropped support for Elixir v1.2. 13 | 14 | ## v1.2.1 15 | 16 | * Fixed port command for OTP versions that support ancillary data sending. 17 | * Fixed `ArgumentError` raising when port gets closed. 18 | 19 | ## v1.2.0 20 | 21 | * Added support for global tags. 22 | 23 | ## v1.1.0 24 | 25 | * Made Statix functions overridable. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Aleksei Magusev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /test/statix/pool_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Statix.PoolingTest do 2 | use Statix.TestCase 3 | 4 | use Statix, runtime_config: true 5 | 6 | @pool_size 3 7 | 8 | setup do 9 | connect(pool_size: @pool_size) 10 | end 11 | 12 | test "starts :pool_size number of ports and randomly picks one" do 13 | uniq_count = 14 | [ 15 | {:increment, [3]}, 16 | {:decrement, [3]}, 17 | {:gauge, [3]}, 18 | {:histogram, [3]}, 19 | {:timing, [3]}, 20 | {:measure, [fn -> nil end]}, 21 | {:set, [3]} 22 | ] 23 | |> Enum.map(fn {function, arguments} -> 24 | apply(__MODULE__, function, ["sample" | arguments]) 25 | end) 26 | |> Enum.uniq_by(fn _ -> 27 | assert_receive {:test_server, %{port: port}, <<"sample:", _::bytes>>} 28 | port 29 | end) 30 | |> length() 31 | 32 | assert uniq_count in 2..@pool_size 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Code linting 12 | uses: lexmag/elixir-actions/.github/workflows/lint.yml@v2 13 | with: 14 | otp-version: "24" 15 | elixir-version: "1.14" 16 | 17 | test: 18 | name: Test suite 19 | runs-on: ubuntu-20.04 20 | 21 | strategy: 22 | matrix: 23 | otp: ["24"] 24 | elixir: ["1.14"] 25 | runtime_config: [true, false] 26 | 27 | include: 28 | - otp: "20" 29 | elixir: "1.6" 30 | runtime_config: false 31 | 32 | env: 33 | MIX_ENV: test 34 | 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - name: Set up Elixir environment 39 | uses: erlef/setup-beam@v1 40 | with: 41 | elixir-version: ${{ matrix.elixir }} 42 | otp-version: ${{ matrix.otp }} 43 | 44 | - name: Install dependencies 45 | run: mix deps.get --only test 46 | 47 | - name: Run tests 48 | run: mix test 49 | env: 50 | STATIX_TEST_RUNTIME_CONFIG: ${{ matrix.runtime_config }} -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Statix.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.4.0" 5 | @source_url "https://github.com/lexmag/statix" 6 | 7 | def project() do 8 | [ 9 | app: :statix, 10 | version: @version, 11 | elixir: "~> 1.3", 12 | deps: deps(), 13 | 14 | # Hex 15 | description: description(), 16 | package: package(), 17 | 18 | # Docs 19 | name: "Statix", 20 | docs: docs() 21 | ] 22 | end 23 | 24 | def application() do 25 | [applications: [:logger]] 26 | end 27 | 28 | defp description() do 29 | "Fast and reliable Elixir client for StatsD-compatible servers." 30 | end 31 | 32 | defp package() do 33 | [ 34 | maintainers: ["Aleksei Magusev", "Andrea Leopardi"], 35 | licenses: ["ISC"], 36 | links: %{"GitHub" => @source_url} 37 | ] 38 | end 39 | 40 | defp deps() do 41 | [{:ex_doc, "~> 0.20.0", only: :dev}] 42 | end 43 | 44 | defp docs() do 45 | [ 46 | main: "Statix", 47 | source_ref: "v#{@version}", 48 | source_url: @source_url, 49 | extras: [ 50 | "README.md", 51 | "CHANGELOG.md" 52 | ] 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/statix/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Statix.ConfigTest do 2 | use Statix.TestCase, async: false 3 | 4 | use Statix, runtime_config: true 5 | 6 | test "connect/1" do 7 | connect(tags: ["tag:test"], prefix: "foo") 8 | 9 | increment("sample", 2) 10 | assert_receive {:test_server, _, "foo.sample:2|c|#tag:test"} 11 | end 12 | 13 | test "global tags when present" do 14 | Application.put_env(:statix, :tags, ["tag:test"]) 15 | 16 | connect() 17 | 18 | increment("sample", 3) 19 | assert_receive {:test_server, _, "sample:3|c|#tag:test"} 20 | 21 | timing("sample", 3, tags: ["foo"]) 22 | assert_receive {:test_server, _, "sample:3|ms|#foo,tag:test"} 23 | after 24 | Application.delete_env(:statix, :tags) 25 | end 26 | 27 | test "global connection-specific tags" do 28 | Application.put_env(:statix, __MODULE__, tags: ["tag:test"]) 29 | 30 | connect() 31 | 32 | set("sample", 4) 33 | assert_receive {:test_server, _, "sample:4|s|#tag:test"} 34 | 35 | gauge("sample", 4, tags: ["foo"]) 36 | assert_receive {:test_server, _, "sample:4|g|#foo,tag:test"} 37 | after 38 | Application.delete_env(:statix, __MODULE__) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/test_server.exs: -------------------------------------------------------------------------------- 1 | defmodule Statix.TestServer do 2 | use GenServer 3 | 4 | def start_link(port, test_module) do 5 | GenServer.start_link(__MODULE__, port, name: test_module) 6 | end 7 | 8 | @impl true 9 | def init(port) do 10 | {:ok, socket} = :gen_udp.open(port, [:binary, active: true]) 11 | {:ok, %{socket: socket, test: nil}} 12 | end 13 | 14 | @impl true 15 | def handle_call({:set_current_test, current_test}, _from, %{test: test} = state) do 16 | if is_nil(test) or is_nil(current_test) do 17 | {:reply, :ok, %{state | test: current_test}} 18 | else 19 | {:reply, :error, state} 20 | end 21 | end 22 | 23 | @impl true 24 | def handle_info({:udp, socket, host, port, packet}, %{socket: socket, test: test} = state) do 25 | metadata = %{host: host, port: port, socket: socket} 26 | send(test, {:test_server, metadata, packet}) 27 | {:noreply, state} 28 | end 29 | 30 | def setup(test_module) do 31 | :ok = set_current_test(test_module, self()) 32 | ExUnit.Callbacks.on_exit(fn -> set_current_test(test_module, nil) end) 33 | end 34 | 35 | defp set_current_test(test_module, test) do 36 | GenServer.call(test_module, {:set_current_test, test}) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 3 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "8e24fc8ff9a50b9f557ff020d6c91a03cded7e59ac3e0eec8a27e771430c7d27"}, 4 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 7 | } 8 | -------------------------------------------------------------------------------- /lib/statix/packet.ex: -------------------------------------------------------------------------------- 1 | defmodule Statix.Packet do 2 | @moduledoc false 3 | 4 | import Bitwise 5 | 6 | def header({n1, n2, n3, n4}, port) do 7 | true = Code.ensure_loaded?(:gen_udp) 8 | 9 | anc_data_part = 10 | if function_exported?(:gen_udp, :send, 5) do 11 | [0, 0, 0, 0] 12 | else 13 | [] 14 | end 15 | 16 | [ 17 | _addr_family = 1, 18 | band(bsr(port, 8), 0xFF), 19 | band(port, 0xFF), 20 | band(n1, 0xFF), 21 | band(n2, 0xFF), 22 | band(n3, 0xFF), 23 | band(n4, 0xFF) 24 | ] ++ anc_data_part 25 | end 26 | 27 | def build(header, name, key, val, options) do 28 | [header, key, ?:, val, ?|, metric_type(name)] 29 | |> set_option(:sample_rate, options[:sample_rate]) 30 | |> set_option(:tags, options[:tags]) 31 | end 32 | 33 | metrics = %{ 34 | counter: "c", 35 | gauge: "g", 36 | histogram: "h", 37 | timing: "ms", 38 | set: "s" 39 | } 40 | 41 | for {name, type} <- metrics do 42 | defp metric_type(unquote(name)), do: unquote(type) 43 | end 44 | 45 | defp set_option(packet, _kind, nil) do 46 | packet 47 | end 48 | 49 | defp set_option(packet, :sample_rate, sample_rate) when is_float(sample_rate) do 50 | [packet | ["|@", :erlang.float_to_binary(sample_rate, [:compact, decimals: 2])]] 51 | end 52 | 53 | defp set_option(packet, :tags, []), do: packet 54 | 55 | defp set_option(packet, :tags, tags) when is_list(tags) do 56 | [packet | ["|#", Enum.join(tags, ",")]] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/statix/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule Statix.Conn do 2 | @moduledoc false 3 | 4 | defstruct [:sock, :header] 5 | 6 | alias Statix.Packet 7 | 8 | require Logger 9 | 10 | def new(host, port) when is_binary(host) do 11 | new(String.to_charlist(host), port) 12 | end 13 | 14 | def new(host, port) when is_list(host) or is_tuple(host) do 15 | case :inet.getaddr(host, :inet) do 16 | {:ok, address} -> 17 | header = Packet.header(address, port) 18 | %__MODULE__{header: header} 19 | 20 | {:error, reason} -> 21 | raise( 22 | "cannot get the IP address for the provided host " <> 23 | "due to reason: #{:inet.format_error(reason)}" 24 | ) 25 | end 26 | end 27 | 28 | def open(%__MODULE__{} = conn) do 29 | {:ok, sock} = :gen_udp.open(0, active: false) 30 | %__MODULE__{conn | sock: sock} 31 | end 32 | 33 | def transmit(%__MODULE__{header: header, sock: sock}, type, key, val, options) 34 | when is_binary(val) and is_list(options) do 35 | result = 36 | header 37 | |> Packet.build(type, key, val, options) 38 | |> transmit(sock) 39 | 40 | if result == {:error, :port_closed} do 41 | Logger.error(fn -> 42 | if(is_atom(sock), do: "", else: "Statix ") <> 43 | "#{inspect(sock)} #{type} metric \"#{key}\" lost value #{val}" <> " due to port closure" 44 | end) 45 | end 46 | 47 | result 48 | end 49 | 50 | defp transmit(packet, sock) do 51 | try do 52 | Port.command(sock, packet) 53 | rescue 54 | ArgumentError -> 55 | {:error, :port_closed} 56 | else 57 | true -> 58 | receive do 59 | {:inet_reply, _port, status} -> status 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Statix 2 | 3 | [![CI Status](https://github.com/lexmag/statix/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/lexmag/statix/actions/workflows/ci.yml) 4 | [![Hex Version](https://img.shields.io/hexpm/v/statix.svg "Hex Version")](https://hex.pm/packages/statix) 5 | 6 | Statix is an Elixir client for StatsD-compatible servers. 7 | It is focused on speed without sacrificing simplicity, completeness, or correctness. 8 | 9 | What makes Statix the fastest library around: 10 | 11 | * direct sending to socket [[1](#direct-sending)] 12 | * caching of the UDP packet header 13 | * connection pooling to distribute the metric sending 14 | * diligent usage of [IO lists](http://jlouisramblings.blogspot.se/2013/07/problematic-traits-in-erlang.html) 15 | 16 | [1] In contrast with process-based clients, Statix has lower memory consumption and higher throughput – Statix v1.0.0 does about __876640__ counter increments per flush: 17 | 18 | ![Statix](https://www.dropbox.com/s/uijh5i8qgzmd11a/statix-v1.0.0.png?raw=1) 19 | 20 |
21 | It is possible to measure that yourself. 22 | 23 | ```elixir 24 | for _ <- 1..10_000 do 25 | Task.start(fn -> 26 | for _ <- 1..10_000 do 27 | StatixSample.increment("sample", 1) 28 | end 29 | end) 30 | end 31 | ``` 32 | 33 | Make sure you have StatsD server running to get more realistic results. 34 | 35 |
36 | 37 | See [the documentation](https://hexdocs.pm/statix) for detailed usage information. 38 | 39 | ## Installation 40 | 41 | Add Statix as a dependency to your `mix.exs` file: 42 | 43 | ```elixir 44 | defp deps() do 45 | [{:statix, ">= 0.0.0"}] 46 | end 47 | ``` 48 | 49 | Then run `mix deps.get` in your shell to fetch the dependencies. 50 | 51 | ## License 52 | 53 | This software is licensed under [the ISC license](LICENSE). 54 | -------------------------------------------------------------------------------- /test/statix/overriding_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Statix.OverridingTest do 2 | @server_port 8225 3 | 4 | use Statix.TestCase, port: @server_port 5 | 6 | Application.put_env(:statix, __MODULE__, port: @server_port) 7 | 8 | use Statix 9 | 10 | def increment(key, value, options) do 11 | super([key, "-overridden"], value, options) 12 | end 13 | 14 | def decrement(key, value, options) do 15 | super([key, "-overridden"], value, options) 16 | end 17 | 18 | def gauge(key, value, options) do 19 | super([key, "-overridden"], value, options) 20 | end 21 | 22 | def histogram(key, value, options) do 23 | super([key, "-overridden"], value, options) 24 | end 25 | 26 | def timing(key, value, options) do 27 | super([key, "-overridden"], value, options) 28 | end 29 | 30 | def measure(key, options, fun) do 31 | super([key, "-measure"], options, fun) 32 | end 33 | 34 | def set(key, value, options) do 35 | super([key, "-overridden"], value, options) 36 | end 37 | 38 | setup do 39 | connect() 40 | end 41 | 42 | test "increment/3" do 43 | increment("sample", 3, tags: ["foo"]) 44 | assert_receive {:test_server, _, "sample-overridden:3|c|#foo"} 45 | end 46 | 47 | test "decrement/3" do 48 | decrement("sample", 3, tags: ["foo"]) 49 | assert_receive {:test_server, _, "sample-overridden:-3|c|#foo"} 50 | end 51 | 52 | test "gauge/3" do 53 | gauge("sample", 3, tags: ["foo"]) 54 | assert_receive {:test_server, _, "sample-overridden:3|g|#foo"} 55 | end 56 | 57 | test "histogram/3" do 58 | histogram("sample", 3, tags: ["foo"]) 59 | assert_receive {:test_server, _, "sample-overridden:3|h|#foo"} 60 | end 61 | 62 | test "timing/3" do 63 | timing("sample", 3, tags: ["foo"]) 64 | assert_receive {:test_server, _, "sample-overridden:3|ms|#foo"} 65 | end 66 | 67 | test "measure/3" do 68 | measure("sample", [tags: ["foo"]], fn -> 69 | :timer.sleep(100) 70 | end) 71 | 72 | assert_receive {:test_server, _, <<"sample-measure-overridden:10", _, "|ms|#foo">>} 73 | end 74 | 75 | test "set/3" do 76 | set("sample", 3, tags: ["foo"]) 77 | assert_receive {:test_server, _, "sample-overridden:3|s|#foo"} 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/statix_test.exs: -------------------------------------------------------------------------------- 1 | runtime_config? = System.get_env("STATIX_TEST_RUNTIME_CONFIG") in ["1", "true"] 2 | 3 | defmodule StatixTest do 4 | use Statix.TestCase 5 | 6 | import ExUnit.CaptureLog 7 | 8 | use Statix, runtime_config: unquote(runtime_config?) 9 | 10 | defp close_port() do 11 | %{pool: pool} = current_statix() 12 | Enum.each(pool, &Port.close/1) 13 | end 14 | 15 | setup do 16 | connect() 17 | end 18 | 19 | test "increment/1,2,3" do 20 | __MODULE__.increment("sample") 21 | assert_receive {:test_server, _, "sample:1|c"} 22 | 23 | increment(["sample"], 2) 24 | assert_receive {:test_server, _, "sample:2|c"} 25 | 26 | increment("sample", 2.1) 27 | assert_receive {:test_server, _, "sample:2.1|c"} 28 | 29 | increment("sample", 3, tags: ["foo:bar", "baz"]) 30 | assert_receive {:test_server, _, "sample:3|c|#foo:bar,baz"} 31 | 32 | increment("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 33 | assert_receive {:test_server, _, "sample:3|c|@1.0|#foo,bar"} 34 | 35 | increment("sample", 3, sample_rate: 0.0) 36 | 37 | refute_received _any 38 | end 39 | 40 | test "decrement/1,2,3" do 41 | __MODULE__.decrement("sample") 42 | assert_receive {:test_server, _, "sample:-1|c"} 43 | 44 | decrement(["sample"], 2) 45 | assert_receive {:test_server, _, "sample:-2|c"} 46 | 47 | decrement("sample", 2.1) 48 | assert_receive {:test_server, _, "sample:-2.1|c"} 49 | 50 | decrement("sample", 3, tags: ["foo:bar", "baz"]) 51 | assert_receive {:test_server, _, "sample:-3|c|#foo:bar,baz"} 52 | decrement("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 53 | 54 | assert_receive {:test_server, _, "sample:-3|c|@1.0|#foo,bar"} 55 | 56 | decrement("sample", 3, sample_rate: 0.0) 57 | 58 | refute_received _any 59 | end 60 | 61 | test "gauge/2,3" do 62 | __MODULE__.gauge(["sample"], 2) 63 | assert_receive {:test_server, _, "sample:2|g"} 64 | 65 | gauge("sample", 2.1) 66 | assert_receive {:test_server, _, "sample:2.1|g"} 67 | 68 | gauge("sample", 3, tags: ["foo:bar", "baz"]) 69 | assert_receive {:test_server, _, "sample:3|g|#foo:bar,baz"} 70 | 71 | gauge("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 72 | assert_receive {:test_server, _, "sample:3|g|@1.0|#foo,bar"} 73 | 74 | gauge("sample", 3, sample_rate: 0.0) 75 | 76 | refute_received _any 77 | end 78 | 79 | test "histogram/2,3" do 80 | __MODULE__.histogram("sample", 2) 81 | assert_receive {:test_server, _, "sample:2|h"} 82 | 83 | histogram("sample", 2.1) 84 | assert_receive {:test_server, _, "sample:2.1|h"} 85 | 86 | histogram("sample", 3, tags: ["foo:bar", "baz"]) 87 | assert_receive {:test_server, _, "sample:3|h|#foo:bar,baz"} 88 | 89 | histogram("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 90 | assert_receive {:test_server, _, "sample:3|h|@1.0|#foo,bar"} 91 | 92 | histogram("sample", 3, sample_rate: 0.0) 93 | 94 | refute_received _any 95 | end 96 | 97 | test "timing/2,3" do 98 | __MODULE__.timing(["sample"], 2) 99 | assert_receive {:test_server, _, "sample:2|ms"} 100 | 101 | timing("sample", 2.1) 102 | assert_receive {:test_server, _, "sample:2.1|ms"} 103 | 104 | timing("sample", 3, tags: ["foo:bar", "baz"]) 105 | assert_receive {:test_server, _, "sample:3|ms|#foo:bar,baz"} 106 | 107 | timing("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 108 | assert_receive {:test_server, _, "sample:3|ms|@1.0|#foo,bar"} 109 | 110 | timing("sample", 3, sample_rate: 0.0) 111 | 112 | refute_received _any 113 | end 114 | 115 | test "measure/2,3" do 116 | expected = "the stuff" 117 | 118 | result = 119 | __MODULE__.measure(["sample"], fn -> 120 | :timer.sleep(100) 121 | expected 122 | end) 123 | 124 | assert_receive {:test_server, _, <<"sample:10", _, "|ms">>} 125 | assert result == expected 126 | 127 | measure("sample", [sample_rate: 1.0, tags: ["foo", "bar"]], fn -> 128 | :timer.sleep(100) 129 | end) 130 | 131 | assert_receive {:test_server, _, <<"sample:10", _, "|ms|@1.0|#foo,bar">>} 132 | 133 | refute_received _any 134 | end 135 | 136 | test "set/2,3" do 137 | __MODULE__.set(["sample"], 2) 138 | assert_receive {:test_server, _, "sample:2|s"} 139 | 140 | set("sample", 2.1) 141 | assert_receive {:test_server, _, "sample:2.1|s"} 142 | 143 | set("sample", 3, tags: ["foo:bar", "baz"]) 144 | assert_receive {:test_server, _, "sample:3|s|#foo:bar,baz"} 145 | 146 | set("sample", 3, sample_rate: 1.0, tags: ["foo", "bar"]) 147 | assert_receive {:test_server, _, "sample:3|s|@1.0|#foo,bar"} 148 | 149 | set("sample", 3, sample_rate: 0.0) 150 | 151 | refute_received _any 152 | end 153 | 154 | test "port closed" do 155 | close_port() 156 | 157 | assert capture_log(fn -> 158 | assert {:error, :port_closed} == increment("sample") 159 | end) =~ "counter metric \"sample\" lost value 1 due to port closure" 160 | 161 | assert capture_log(fn -> 162 | assert {:error, :port_closed} == decrement("sample") 163 | end) =~ "counter metric \"sample\" lost value -1 due to port closure" 164 | 165 | assert capture_log(fn -> 166 | assert {:error, :port_closed} == gauge("sample", 2) 167 | end) =~ "gauge metric \"sample\" lost value 2 due to port closure" 168 | 169 | assert capture_log(fn -> 170 | assert {:error, :port_closed} == histogram("sample", 3) 171 | end) =~ "histogram metric \"sample\" lost value 3 due to port closure" 172 | 173 | assert capture_log(fn -> 174 | assert {:error, :port_closed} == timing("sample", 2.5) 175 | end) =~ "timing metric \"sample\" lost value 2.5 due to port closure" 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/statix.ex: -------------------------------------------------------------------------------- 1 | defmodule Statix do 2 | @moduledoc """ 3 | Writer for [StatsD](https://github.com/etsy/statsd)-compatible servers. 4 | 5 | To get started with Statix, you have to create a module that calls `use 6 | Statix`, like this: 7 | 8 | defmodule MyApp.Statix do 9 | use Statix 10 | end 11 | 12 | This will make `MyApp.Statix` a Statix connection that implements the `Statix` 13 | behaviour. This connection can be started with the `MyApp.Statix.connect/1` 14 | function (see the `c:connect/1` callback) and a few functions can be called on 15 | it to report metrics to the StatsD-compatible server read from the 16 | configuration. Usually, `connect/1` is called in your application's 17 | `c:Application.start/2` callback: 18 | 19 | def start(_type, _args) do 20 | :ok = MyApp.Statix.connect() 21 | 22 | # ... 23 | end 24 | 25 | ## Configuration 26 | 27 | Statix can be configured either globally or on a per-connection basis. 28 | 29 | The global configuration will affect all Statix connections created with 30 | `use Statix`; it can be specified by configuring the `:statix` application: 31 | 32 | config :statix, 33 | prefix: "sample", 34 | host: "stats.tld", 35 | port: 8181 36 | 37 | The per-connection configuration can be specified by configuring each specific 38 | connection module under the `:statix` application: 39 | 40 | config :statix, MyApp.Statix, 41 | port: 8123 42 | 43 | The following is a list of all the supported options: 44 | 45 | * `:prefix` - (binary) all metrics sent to the StatsD-compatible 46 | server through the configured Statix connection will be prefixed with the 47 | value of this option. By default this option is not present. 48 | * `:host` - (binary) the host where the StatsD-compatible server is running. 49 | Defaults to `"127.0.0.1"`. 50 | * `:port` - (integer) the port (on `:host`) where the StatsD-compatible 51 | server is running. Defaults to `8125`. 52 | * `:tags` - ([binary]) a list of global tags that will be sent with all 53 | metrics. By default this option is not present. 54 | See the "Tags" section for more information. 55 | * `:pool_size` - (integer) number of ports used to distribute the metric sending. 56 | Defaults to `1`. See the "Pooling" section for more information. 57 | 58 | By default, the configuration is evaluated once, at compile time. 59 | If you plan on changing the configuration at runtime, you must specify the 60 | `:runtime_config` option to be `true` when calling `use Statix`: 61 | 62 | defmodule MyApp.Statix do 63 | use Statix, runtime_config: true 64 | end 65 | 66 | ## Tags 67 | 68 | Tags are a way of adding dimensions to metrics: 69 | 70 | MyApp.Statix.gauge("memory", 1, tags: ["region:east"]) 71 | 72 | In the example above, the `memory` measurement has been tagged with 73 | `region:east`. Not all StatsD-compatible servers support this feature. 74 | 75 | Tags could also be added globally to be included in every metric sent: 76 | 77 | config :statix, tags: ["env:\#{Mix.env()}"] 78 | 79 | 80 | ## Sampling 81 | 82 | All the callbacks from the `Statix` behaviour that accept options support 83 | sampling via the `:sample_rate` option (see also the `t:options/0` type). 84 | 85 | MyApp.Statix.increment("page_view", 1, sample_rate: 0.5) 86 | 87 | In the example above, the UDP packet will only be sent to the server about 88 | half of the time, but the resulting value will be adjusted on the server 89 | according to the given sample rate. 90 | 91 | ## Pooling 92 | 93 | Statix transmits data using [ports](https://hexdocs.pm/elixir/Port.html). 94 | 95 | If a port is busy when you try to send a command to it, the sender may be suspended and some blocking may occur. This becomes more of an issue in highly concurrent environments. 96 | 97 | In order to get around that, Statix allows you to start multiple ports, and randomly picks one at the time of transmit. 98 | 99 | This option can be configured via the `:pool_size` option: 100 | 101 | config :statix, MyApp.Statix, 102 | pool_size: 3 103 | 104 | """ 105 | 106 | alias __MODULE__.Conn 107 | 108 | @type key :: iodata 109 | @type options :: [sample_rate: float, tags: [String.t()]] 110 | @type on_send :: :ok | {:error, term} 111 | 112 | @doc """ 113 | Same as `connect([])`. 114 | """ 115 | @callback connect() :: :ok 116 | 117 | @doc """ 118 | Opens the connection to the StatsD-compatible server. 119 | 120 | The configuration is read from the environment for the `:statix` application 121 | (both globally and per connection). 122 | 123 | The given `options` override the configuration read from the application environment. 124 | """ 125 | @callback connect(options :: keyword) :: :ok 126 | 127 | @doc """ 128 | Increments the StatsD counter identified by `key` by the given `value`. 129 | 130 | `value` is supposed to be zero or positive and `c:decrement/3` should be 131 | used for negative values. 132 | 133 | ## Examples 134 | 135 | iex> MyApp.Statix.increment("hits", 1, []) 136 | :ok 137 | 138 | """ 139 | @callback increment(key, value :: number, options) :: on_send 140 | 141 | @doc """ 142 | Same as `increment(key, 1, [])`. 143 | """ 144 | @callback increment(key) :: on_send 145 | 146 | @doc """ 147 | Same as `increment(key, value, [])`. 148 | """ 149 | @callback increment(key, value :: number) :: on_send 150 | 151 | @doc """ 152 | Decrements the StatsD counter identified by `key` by the given `value`. 153 | 154 | Works same as `c:increment/3` but subtracts `value` instead of adding it. For 155 | this reason `value` should be zero or negative. 156 | 157 | ## Examples 158 | 159 | iex> MyApp.Statix.decrement("open_connections", 1, []) 160 | :ok 161 | 162 | """ 163 | @callback decrement(key, value :: number, options) :: on_send 164 | 165 | @doc """ 166 | Same as `decrement(key, 1, [])`. 167 | """ 168 | @callback decrement(key) :: on_send 169 | 170 | @doc """ 171 | Same as `decrement(key, value, [])`. 172 | """ 173 | @callback decrement(key, value :: number) :: on_send 174 | 175 | @doc """ 176 | Writes to the StatsD gauge identified by `key`. 177 | 178 | ## Examples 179 | 180 | iex> MyApp.Statix.gauge("cpu_usage", 0.83, []) 181 | :ok 182 | 183 | """ 184 | @callback gauge(key, value :: String.Chars.t(), options) :: on_send 185 | 186 | @doc """ 187 | Same as `gauge(key, value, [])`. 188 | """ 189 | @callback gauge(key, value :: String.Chars.t()) :: on_send 190 | 191 | @doc """ 192 | Writes `value` to the histogram identified by `key`. 193 | 194 | Not all StatsD-compatible servers support histograms. An example of a such 195 | server [statsite](https://github.com/statsite/statsite). 196 | 197 | ## Examples 198 | 199 | iex> MyApp.Statix.histogram("online_users", 123, []) 200 | :ok 201 | 202 | """ 203 | @callback histogram(key, value :: String.Chars.t(), options) :: on_send 204 | 205 | @doc """ 206 | Same as `histogram(key, value, [])`. 207 | """ 208 | @callback histogram(key, value :: String.Chars.t()) :: on_send 209 | 210 | @doc """ 211 | Same as `timing(key, value, [])`. 212 | """ 213 | @callback timing(key, value :: String.Chars.t()) :: on_send 214 | 215 | @doc """ 216 | Writes the given `value` to the StatsD timing identified by `key`. 217 | 218 | `value` is expected in milliseconds. 219 | 220 | ## Examples 221 | 222 | iex> MyApp.Statix.timing("rendering", 12, []) 223 | :ok 224 | 225 | """ 226 | @callback timing(key, value :: String.Chars.t(), options) :: on_send 227 | 228 | @doc """ 229 | Writes the given `value` to the StatsD set identified by `key`. 230 | 231 | ## Examples 232 | 233 | iex> MyApp.Statix.set("unique_visitors", "user1", []) 234 | :ok 235 | 236 | """ 237 | @callback set(key, value :: String.Chars.t(), options) :: on_send 238 | 239 | @doc """ 240 | Same as `set(key, value, [])`. 241 | """ 242 | @callback set(key, value :: String.Chars.t()) :: on_send 243 | 244 | @doc """ 245 | Measures the execution time of the given `function` and writes that to the 246 | StatsD timing identified by `key`. 247 | 248 | This function returns the value returned by `function`, making it suitable for 249 | easily wrapping existing code. 250 | 251 | ## Examples 252 | 253 | iex> MyApp.Statix.measure("integer_to_string", [], fn -> Integer.to_string(123) end) 254 | "123" 255 | 256 | """ 257 | @callback measure(key, options, function :: (() -> result)) :: result when result: var 258 | 259 | @doc """ 260 | Same as `measure(key, [], function)`. 261 | """ 262 | @callback measure(key, function :: (() -> result)) :: result when result: var 263 | 264 | defmacro __using__(opts) do 265 | current_statix = 266 | if Keyword.get(opts, :runtime_config, false) do 267 | quote do 268 | @statix_key Module.concat(__MODULE__, :__statix__) 269 | 270 | def connect(options \\ []) do 271 | statix = Statix.new(__MODULE__, options) 272 | Application.put_env(:statix, @statix_key, statix) 273 | 274 | Statix.open(statix) 275 | :ok 276 | end 277 | 278 | @compile {:inline, [current_statix: 0]} 279 | 280 | defp current_statix() do 281 | Application.fetch_env!(:statix, @statix_key) 282 | end 283 | end 284 | else 285 | quote do 286 | @statix Statix.new(__MODULE__, []) 287 | 288 | def connect(options \\ []) do 289 | if @statix != Statix.new(__MODULE__, options) do 290 | raise( 291 | "the current configuration for #{inspect(__MODULE__)} differs from " <> 292 | "the one that was given during the compilation.\n" <> 293 | "Be sure to use :runtime_config option " <> 294 | "if you want to have different configurations" 295 | ) 296 | end 297 | 298 | Statix.open(@statix) 299 | :ok 300 | end 301 | 302 | @compile {:inline, [current_statix: 0]} 303 | 304 | defp current_statix(), do: @statix 305 | end 306 | end 307 | 308 | quote location: :keep do 309 | @behaviour Statix 310 | 311 | unquote(current_statix) 312 | 313 | def increment(key, val \\ 1, options \\ []) when is_number(val) do 314 | Statix.transmit(current_statix(), :counter, key, val, options) 315 | end 316 | 317 | def decrement(key, val \\ 1, options \\ []) when is_number(val) do 318 | Statix.transmit(current_statix(), :counter, key, [?-, to_string(val)], options) 319 | end 320 | 321 | def gauge(key, val, options \\ []) do 322 | Statix.transmit(current_statix(), :gauge, key, val, options) 323 | end 324 | 325 | def histogram(key, val, options \\ []) do 326 | Statix.transmit(current_statix(), :histogram, key, val, options) 327 | end 328 | 329 | def timing(key, val, options \\ []) do 330 | Statix.transmit(current_statix(), :timing, key, val, options) 331 | end 332 | 333 | def measure(key, options \\ [], fun) when is_function(fun, 0) do 334 | {elapsed, result} = :timer.tc(fun) 335 | 336 | timing(key, div(elapsed, 1000), options) 337 | 338 | result 339 | end 340 | 341 | def set(key, val, options \\ []) do 342 | Statix.transmit(current_statix(), :set, key, val, options) 343 | end 344 | 345 | defoverridable( 346 | increment: 3, 347 | decrement: 3, 348 | gauge: 3, 349 | histogram: 3, 350 | timing: 3, 351 | measure: 3, 352 | set: 3 353 | ) 354 | end 355 | end 356 | 357 | defstruct [:conn, :tags, :pool] 358 | 359 | @doc false 360 | def new(module, options) do 361 | config = get_config(module, options) 362 | conn = Conn.new(config.host, config.port) 363 | header = IO.iodata_to_binary([conn.header | config.prefix]) 364 | 365 | %__MODULE__{ 366 | conn: %{conn | header: header}, 367 | pool: build_pool(module, config.pool_size), 368 | tags: config.tags 369 | } 370 | end 371 | 372 | defp build_pool(module, 1), do: [module] 373 | 374 | defp build_pool(module, size) do 375 | Enum.map(1..size, &:"#{module}-#{&1}") 376 | end 377 | 378 | @doc false 379 | def open(%__MODULE__{conn: conn, pool: pool}) do 380 | Enum.each(pool, fn name -> 381 | %{sock: sock} = Conn.open(conn) 382 | Process.register(sock, name) 383 | end) 384 | end 385 | 386 | @doc false 387 | def transmit( 388 | %{conn: conn, pool: pool, tags: tags}, 389 | type, 390 | key, 391 | value, 392 | options 393 | ) 394 | when (is_binary(key) or is_list(key)) and is_list(options) do 395 | sample_rate = Keyword.get(options, :sample_rate) 396 | 397 | if is_nil(sample_rate) or sample_rate >= :rand.uniform() do 398 | options = put_global_tags(options, tags) 399 | 400 | %{conn | sock: pick_name(pool)} 401 | |> Conn.transmit(type, key, to_string(value), options) 402 | else 403 | :ok 404 | end 405 | end 406 | 407 | defp pick_name([name]), do: name 408 | defp pick_name(pool), do: Enum.random(pool) 409 | 410 | defp get_config(module, overrides) do 411 | {module_env, global_env} = 412 | :statix 413 | |> Application.get_all_env() 414 | |> Keyword.pop(module, []) 415 | 416 | env = module_env ++ global_env 417 | options = overrides ++ env 418 | 419 | tags = 420 | Keyword.get_lazy(overrides, :tags, fn -> 421 | env |> Keyword.get_values(:tags) |> Enum.concat() 422 | end) 423 | 424 | %{ 425 | prefix: build_prefix(env, overrides), 426 | host: Keyword.get(options, :host, "127.0.0.1"), 427 | port: Keyword.get(options, :port, 8125), 428 | pool_size: Keyword.get(options, :pool_size, 1), 429 | tags: tags 430 | } 431 | end 432 | 433 | defp build_prefix(env, overrides) do 434 | case Keyword.fetch(overrides, :prefix) do 435 | {:ok, prefix} -> 436 | [prefix, ?.] 437 | 438 | :error -> 439 | env 440 | |> Keyword.get_values(:prefix) 441 | |> Enum.map_join(&(&1 && [&1, ?.])) 442 | end 443 | end 444 | 445 | defp put_global_tags(options, []), do: options 446 | 447 | defp put_global_tags(options, tags) do 448 | Keyword.update(options, :tags, tags, &(&1 ++ tags)) 449 | end 450 | end 451 | --------------------------------------------------------------------------------