├── .formatter.exs ├── .gitignore ├── Dockerfile ├── Dockerfile.clickhouse ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── vertex.ex ├── vertex │ ├── application.ex │ ├── backend.ex │ ├── backend │ │ ├── clickhouse.ex │ │ ├── logger.ex │ │ └── testing.ex │ ├── mailer.ex │ ├── metric.ex │ └── project.ex ├── vertex_web.ex └── vertex_web │ ├── authenticate.ex │ ├── controllers │ ├── event_controller.ex │ └── ping_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ ├── telemetry.ex │ └── views │ ├── error_helpers.ex │ └── error_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── setup.sql └── test ├── support └── conn_case.ex ├── test_helper.exs ├── vertex ├── backends │ ├── clickhouse_test.exs │ ├── logger_test.exs │ └── testing_test.exs └── project_test.exs └── vertex_web ├── controllers ├── event_controller_test.exs └── ping_controller_test.exs └── views └── error_view_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 3rd-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 | analytics-*.tar 24 | 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir-phoenix:latest 2 | 3 | ARG SECRET_KEY_BASE 4 | ARG VERTEX_ACCESS_TOKEN 5 | ARG CLICKHOUSE_DATABASE 6 | ARG CLICKHOUSE_URL 7 | ARG CLICKHOUSE_USER 8 | ARG CLICKHOUSE_PASSWORD 9 | 10 | ENV MIX_ENV=prod 11 | 12 | RUN mix local.hex --force 13 | RUN mix local.rebar --force 14 | 15 | ADD mix.exs mix.lock ./ 16 | RUN mix do deps.get, deps.compile 17 | 18 | ADD . . 19 | 20 | RUN mix do compile, phx.digest 21 | 22 | CMD mix phx.server 23 | -------------------------------------------------------------------------------- /Dockerfile.clickhouse: -------------------------------------------------------------------------------- 1 | FROM yandex/clickhouse-server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vertex 2 | 3 | A server for storing multi-tenant metrics in [ClickHouse](https://clickhouse.com/). 4 | 5 | ## Motivation 6 | 7 | Unlike StatsD, events are scoped by tenant. That makes it possible to have alerts and reporting on the tenant level. 8 | 9 | For example, if you'd like to when accounts haven't logged in over for 2 weeks. The app could publish an event `login.success` and attach their `account_id` as the `tenant`. Then reporting and alerts could be triggered when `login.success` event is missing over a window of 2 weeks. 10 | 11 | This approach is also more economical than DataDog or similar logging systems. It doesn't require expensive hosting, and the same infrastructure can support multiple projects for no additional cost. 12 | 13 | ## Usage 14 | 15 | ### Sending a single metric 16 | 17 | ```bash 18 | curl http://localhost:4000/event \ 19 | --header "authorization: Bearer " \ 20 | --header "content-type: application/json" \ 21 | --data '{ "tenant": "1234", "event": "order.success", "tags": ["enterprise-plan", "sandbox"] }' 22 | ``` 23 | 24 | ### Sending a batch of metrics 25 | 26 | ```bash 27 | curl http://localhost:4000/events \ 28 | --header "authorization: Bearer " \ 29 | --header "content-type: application/json" \ 30 | --data '[{"tenant": "1234", "event": "account.login"}, {"tenant": "1234", "event": "order.success"}]' 31 | ``` 32 | 33 | ## Deployment 34 | 35 | Create the database using [`setup.sql`](/priv/setup.sql): 36 | 37 | ```bash 38 | cat priv/setup.sql | clickhouse-client --host --database= --user=default --password= 39 | ``` 40 | 41 | Set up the environment variables: 42 | 43 | - `CLICKHOUSE_URL`: URL of the ClickHouse cluster. Including the port (usually `:8123`). 44 | - `CLICKHOUSE_DATABASE`: Name of the ClickHouse database. 45 | - `CLICKHOUSE_USER`: Name of ClickHouse user. 46 | - `CLICKHOUSE_PASSWORD`: Password for ClickHouse user. 47 | 48 | For each project that can access the server, create a record in `projects.json`. 49 | 50 | ```json 51 | { 52 | "my-project": "my-access-token", 53 | "my-other-project": "my-other-access-token" 54 | } 55 | ``` 56 | 57 | The key is the name of the project, and the value is the access token. 58 | 59 | ## Future ideas 60 | 61 | - Support triggers: send an email when something happens 62 | - Support expectations: send an email when something doesn't happen 63 | 64 | ## License 65 | 66 | MIT 67 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :vertex, VertexWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [view: VertexWeb.ErrorView, accepts: ~w(json), layout: false], 14 | pubsub_server: Vertex.PubSub, 15 | live_view: [signing_salt: "cTRQj7pG"] 16 | 17 | # Configures the mailer 18 | # 19 | # By default it uses the "Local" adapter which stores the emails 20 | # locally. You can see the emails in your browser, at "/dev/mailbox". 21 | # 22 | # For production it's recommended to configure a different adapter 23 | # at the `config/runtime.exs`. 24 | config :vertex, Vertex.Mailer, adapter: Swoosh.Adapters.Local 25 | 26 | # Swoosh API client is needed for adapters other than SMTP. 27 | config :swoosh, :api_client, false 28 | 29 | # Configures Elixir's Logger 30 | config :logger, :console, 31 | format: "$time $metadata[$level] $message\n", 32 | metadata: [:request_id] 33 | 34 | # Use Jason for JSON parsing in Phoenix 35 | config :phoenix, :json_library, Jason 36 | 37 | # Import environment specific config. This must remain at the bottom 38 | # of this file so it overrides the configuration defined above. 39 | import_config "#{config_env()}.exs" 40 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :vertex, VertexWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "tXwvaf5rViA/xKGp3vc0cDAKVTuvxb8VQfeIujOvi2el60azM3HLlw9yEQIUPx/3", 17 | watchers: [] 18 | 19 | # ## SSL Support 20 | # 21 | # In order to use HTTPS in development, a self-signed 22 | # certificate can be generated by running the following 23 | # Mix task: 24 | # 25 | # mix phx.gen.cert 26 | # 27 | # Note that this task requires Erlang/OTP 20 or later. 28 | # Run `mix help phx.gen.cert` for more information. 29 | # 30 | # The `http:` config above can be replaced with: 31 | # 32 | # https: [ 33 | # port: 4001, 34 | # cipher_suite: :strong, 35 | # keyfile: "priv/cert/selfsigned_key.pem", 36 | # certfile: "priv/cert/selfsigned.pem" 37 | # ], 38 | # 39 | # If desired, both `http:` and `https:` keys can be 40 | # configured to run both http and https servers on 41 | # different ports. 42 | 43 | # Do not include metadata nor timestamps in development logs 44 | config :logger, :console, format: "[$level] $message\n" 45 | 46 | # Set a higher stacktrace during development. Avoid configuring such 47 | # in production as building large stacktraces may be expensive. 48 | config :phoenix, :stacktrace_depth, 20 49 | 50 | # Initialize plugs at runtime for faster development compilation 51 | config :phoenix, :plug_init_mode, :runtime 52 | 53 | config :vertex, backend: Vertex.Backend.Logger 54 | 55 | config :vertex, 56 | projects: %{ 57 | "site1" => "fake-access-token" 58 | } 59 | 60 | config :vertex, :clickhouse, 61 | database: "analytics", 62 | url: "http://localhost:8123", 63 | user: "default", 64 | password: "secret" 65 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :vertex, VertexWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :vertex, VertexWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :vertex, VertexWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/vertex start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :vertex, VertexWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :vertex, VertexWeb.Endpoint, 40 | url: [host: host, port: 443, scheme: "https"], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | 51 | # ## Configuring the mailer 52 | # 53 | # In production you need to configure the mailer to use a different adapter. 54 | # Also, you may need to configure the Swoosh API client of your choice if you 55 | # are not using SMTP. Here is an example of the configuration: 56 | # 57 | # config :vertex, Vertex.Mailer, 58 | # adapter: Swoosh.Adapters.Mailgun, 59 | # api_key: System.get_env("MAILGUN_API_KEY"), 60 | # domain: System.get_env("MAILGUN_DOMAIN") 61 | # 62 | # For this example you need include a HTTP client required by Swoosh API client. 63 | # Swoosh supports Hackney and Finch out of the box: 64 | # 65 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 66 | # 67 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 68 | 69 | config :vertex, backend: Vertex.Backend.Clickhouse 70 | 71 | config :vertex, 72 | projects: %{ 73 | default: System.get_env("VERTEX_ACCESS_TOKEN") 74 | } 75 | 76 | config :vertex, :clickhouse, 77 | database: System.get_env("CLICKHOUSE_DATABASE"), 78 | url: System.get_env("CLICKHOUSE_URL"), 79 | user: System.get_env("CLICKHOUSE_USER"), 80 | password: System.get_env("CLICKHOUSE_PASSWORD") 81 | end 82 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :vertex, VertexWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "tU8VChA0PQrmZ4JziE16ADMY5fLjvG2lVcETNrdS+mZfGV1enLSnqpMoO7ysczsf", 8 | server: false 9 | 10 | # In test we don't send emails. 11 | config :vertex, Vertex.Mailer, adapter: Swoosh.Adapters.Test 12 | 13 | # Print only warnings and errors during test 14 | config :logger, level: :warn 15 | 16 | # Initialize plugs at runtime for faster test compilation 17 | config :phoenix, :plug_init_mode, :runtime 18 | 19 | config :vertex, backend: Vertex.Backend.Testing 20 | 21 | config :vertex, 22 | projects: %{ 23 | "site1" => "fake-access-token" 24 | } 25 | 26 | config :vertex, :clickhouse, 27 | database: "analytics", 28 | url: "http://localhost:6001", 29 | user: "default", 30 | password: "secret" 31 | -------------------------------------------------------------------------------- /lib/vertex.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex do 2 | @moduledoc """ 3 | Vertex keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/vertex/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.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 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | Vertex.Project, 12 | # Start the Telemetry supervisor 13 | VertexWeb.Telemetry, 14 | # Start the PubSub system 15 | {Phoenix.PubSub, name: Vertex.PubSub}, 16 | # Start the Endpoint (http/https) 17 | VertexWeb.Endpoint 18 | # Start a worker by calling: Vertex.Worker.start_link(arg) 19 | # {Vertex.Worker, arg} 20 | ] 21 | 22 | # See https://hexdocs.pm/elixir/Supervisor.html 23 | # for other strategies and supported options 24 | opts = [strategy: :one_for_one, name: Vertex.Supervisor] 25 | Supervisor.start_link(children, opts) 26 | end 27 | 28 | # Tell Phoenix to update the endpoint configuration 29 | # whenever the application is updated. 30 | @impl true 31 | def config_change(changed, _new, removed) do 32 | VertexWeb.Endpoint.config_change(changed, removed) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/vertex/backend.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend do 2 | alias Vertex.Metric 3 | 4 | @callback record(metrics :: list(Metric.t) | Metric.t) :: :ok 5 | end 6 | -------------------------------------------------------------------------------- /lib/vertex/backend/clickhouse.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.Clickhouse do 2 | @behaviour Vertex.Backend 3 | 4 | def record(metrics) when is_list(metrics) do 5 | config = Application.get_env(:vertex, :clickhouse) 6 | database = Keyword.fetch!(config, :database) 7 | url = Keyword.fetch!(config, :url) 8 | user = Keyword.fetch!(config, :user) 9 | password = Keyword.fetch!(config, :password) 10 | 11 | query = build_query(metrics) 12 | 13 | %{status: 200} = 14 | Req.post!(url, 15 | params: %{database: database, query: ""}, 16 | body: query, 17 | auth: {user, password} 18 | ) 19 | 20 | :ok 21 | end 22 | 23 | def record(metric), do: record([metric]) 24 | 25 | defp build_query(metrics) do 26 | values = Enum.map_join(metrics, ",\n", &build_values/1) 27 | 28 | """ 29 | INSERT INTO metrics (project, tenant, event, tags) 30 | FORMAT Values #{values} 31 | """ 32 | end 33 | 34 | defp build_values(metric) do 35 | tags = Enum.map_join(metric.tags, ",", &"'#{&1}'") 36 | 37 | ~s|('#{metric.project}', '#{metric.tenant}', '#{metric.event}', [#{tags}])| 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/vertex/backend/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.Logger do 2 | @behaviour Vertex.Backend 3 | 4 | def record(metrics) when is_list(metrics) do 5 | Enum.each(metrics, &record/1) 6 | end 7 | 8 | def record(metric) do 9 | metric 10 | |> Map.from_struct() 11 | |> Jason.encode!() 12 | |> IO.puts() 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/vertex/backend/testing.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.Testing do 2 | @behaviour Vertex.Backend 3 | 4 | @name __MODULE__ 5 | 6 | def start_link do 7 | Agent.start_link(fn -> [] end, name: @name) 8 | end 9 | 10 | def record(metrics) when is_list(metrics) do 11 | Agent.update(@name, fn state -> state ++ metrics end) 12 | end 13 | 14 | def record(metric) do 15 | record([metric]) 16 | end 17 | 18 | def metrics do 19 | Agent.get(@name, & &1) 20 | end 21 | 22 | def reset! do 23 | Agent.update(@name, fn _state -> [] end) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/vertex/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Mailer do 2 | use Swoosh.Mailer, otp_app: :vertex 3 | end 4 | -------------------------------------------------------------------------------- /lib/vertex/metric.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Metric do 2 | @enforce_keys [:project, :event] 3 | defstruct project: nil, 4 | tenant: nil, 5 | event: nil, 6 | tags: [] 7 | end 8 | -------------------------------------------------------------------------------- /lib/vertex/project.ex: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Project do 2 | use Agent 3 | 4 | @name __MODULE__ 5 | 6 | def start_link(_opts) do 7 | Agent.start_link(&load/0, name: @name) 8 | end 9 | 10 | defp load() do 11 | if File.exists?("projects.json") do 12 | File.read!("projects.json") |> Jason.decode!() 13 | else 14 | Application.get_env(:vertex, :projects) 15 | end 16 | |> Enum.map(fn {k, v} -> {v, k} end) 17 | |> Enum.into(%{}) 18 | end 19 | 20 | def get(nil), do: nil 21 | 22 | def get(access_token) do 23 | Agent.get(@name, fn state -> state[access_token] end) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/vertex_web.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use VertexWeb, :controller 9 | use VertexWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: VertexWeb 23 | 24 | import Plug.Conn 25 | import VertexWeb.Gettext 26 | alias VertexWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/analytics_web/templates", 34 | namespace: VertexWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def router do 46 | quote do 47 | use Phoenix.Router 48 | 49 | import Plug.Conn 50 | import Phoenix.Controller 51 | end 52 | end 53 | 54 | def channel do 55 | quote do 56 | use Phoenix.Channel 57 | import VertexWeb.Gettext 58 | end 59 | end 60 | 61 | defp view_helpers do 62 | quote do 63 | # Import basic rendering functionality (render, render_layout, etc) 64 | import Phoenix.View 65 | 66 | import VertexWeb.ErrorHelpers 67 | import VertexWeb.Gettext 68 | alias VertexWeb.Router.Helpers, as: Routes 69 | end 70 | end 71 | 72 | @doc """ 73 | When used, dispatch to the appropriate controller/view/etc. 74 | """ 75 | defmacro __using__(which) when is_atom(which) do 76 | apply(__MODULE__, which, []) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/vertex_web/authenticate.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Authenticate do 2 | import Plug.Conn 3 | import Phoenix.Controller, only: [json: 2] 4 | 5 | alias Vertex.Project 6 | 7 | def init(options), do: options 8 | 9 | def call(conn, _opts) do 10 | authorization = get_req_header(conn, "authorization") 11 | access_token = get_access_token(authorization) 12 | project = Project.get(access_token) 13 | 14 | if project do 15 | assign(conn, :project, project) 16 | else 17 | conn 18 | |> put_status(:unauthorized) 19 | |> json(%{message: "invalid access token"}) 20 | |> halt() 21 | end 22 | end 23 | 24 | defp get_access_token(["Bearer " <> access_token]), do: access_token 25 | defp get_access_token(_), do: nil 26 | end 27 | -------------------------------------------------------------------------------- /lib/vertex_web/controllers/event_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.EventController do 2 | use VertexWeb, :controller 3 | 4 | alias Vertex.Metric 5 | 6 | def create(conn, params) do 7 | metric = %Metric{ 8 | project: conn.assigns.project, 9 | tenant: params["tenant"], 10 | event: params["event"], 11 | tags: params["tags"] || [] 12 | } 13 | 14 | spawn_record(metric) 15 | 16 | conn 17 | |> put_status(:created) 18 | |> json(%{}) 19 | end 20 | 21 | def batch(conn, params) do 22 | list = params["_json"] 23 | metrics = Enum.map(list, & %Metric{ 24 | project: conn.assigns.project, 25 | tenant: &1["tenant"], 26 | event: &1["event"], 27 | tags: &1["tags"] || [] 28 | }) 29 | 30 | spawn_record(metrics) 31 | 32 | conn 33 | |> put_status(:created) 34 | |> json(%{}) 35 | end 36 | 37 | defp spawn_record(metrics) do 38 | backend = Application.get_env(:vertex, :backend) 39 | 40 | spawn(fn -> 41 | backend.record(metrics) 42 | end) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/vertex_web/controllers/ping_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.PingController do 2 | use VertexWeb, :controller 3 | 4 | def index(conn, _params) do 5 | text(conn, "pong") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/vertex_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :vertex 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_analytics_key", 10 | signing_salt: "PIawpeaq" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :vertex, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | plug Phoenix.CodeReloader 29 | end 30 | 31 | plug Phoenix.LiveDashboard.RequestLogger, 32 | param_key: "request_logger", 33 | cookie_key: "request_logger" 34 | 35 | plug Plug.RequestId 36 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 37 | 38 | plug Plug.Parsers, 39 | parsers: [:urlencoded, :multipart, :json], 40 | pass: ["*/*"], 41 | json_decoder: Phoenix.json_library() 42 | 43 | plug Plug.MethodOverride 44 | plug Plug.Head 45 | plug Plug.Session, @session_options 46 | plug VertexWeb.Router 47 | end 48 | -------------------------------------------------------------------------------- /lib/vertex_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import VertexWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :vertex 24 | end 25 | -------------------------------------------------------------------------------- /lib/vertex_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Router do 2 | use VertexWeb, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | plug VertexWeb.Authenticate 7 | end 8 | 9 | scope "/", VertexWeb do 10 | get "/ping", PingController, :index 11 | end 12 | 13 | scope "/", VertexWeb do 14 | pipe_through :api 15 | 16 | post "/event", EventController, :create 17 | post "/events", EventController, :batch 18 | end 19 | 20 | # Enables LiveDashboard only for development 21 | # 22 | # If you want to use the LiveDashboard in production, you should put 23 | # it behind authentication and allow only admins to access it. 24 | # If your application does not have an admins-only section yet, 25 | # you can use Plug.BasicAuth to set up some basic authentication 26 | # as long as you are also using SSL (which you should anyway). 27 | if Mix.env() in [:dev, :test] do 28 | import Phoenix.LiveDashboard.Router 29 | 30 | scope "/" do 31 | pipe_through [:fetch_session, :protect_from_forgery] 32 | 33 | live_dashboard "/dashboard", metrics: VertexWeb.Telemetry 34 | end 35 | end 36 | 37 | # Enables the Swoosh mailbox preview in development. 38 | # 39 | # Note that preview only shows emails that were sent by the same 40 | # node running the Phoenix server. 41 | if Mix.env() == :dev do 42 | scope "/dev" do 43 | pipe_through [:fetch_session, :protect_from_forgery] 44 | 45 | forward "/mailbox", Plug.Swoosh.MailboxPreview 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/vertex_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {VertexWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/vertex_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # When using gettext, we typically pass the strings we want 11 | # to translate as a static argument: 12 | # 13 | # # Translate "is invalid" in the "errors" domain 14 | # dgettext("errors", "is invalid") 15 | # 16 | # # Translate the number of files with plural rules 17 | # dngettext("errors", "1 file", "%{count} files", count) 18 | # 19 | # Because the error messages we show in our forms and APIs 20 | # are defined inside Ecto, we need to translate them dynamically. 21 | # This requires us to call the Gettext module passing our gettext 22 | # backend as first argument. 23 | # 24 | # Note we use the "errors" domain, which means translations 25 | # should be written to the errors.po file. The :count option is 26 | # set by Ecto and indicates we should also apply plural rules. 27 | if count = opts[:count] do 28 | Gettext.dngettext(VertexWeb.Gettext, "errors", msg, msg, count, opts) 29 | else 30 | Gettext.dgettext(VertexWeb.Gettext, "errors", msg, opts) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/vertex_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.ErrorView do 2 | use VertexWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.json", _assigns) do 7 | # %{errors: %{detail: "Internal Server Error"}} 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.json" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Vertex.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :vertex, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Vertex.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.10"}, 37 | {:phoenix_live_dashboard, "~> 0.6"}, 38 | {:swoosh, "~> 1.3"}, 39 | {:telemetry_metrics, "~> 0.6"}, 40 | {:telemetry_poller, "~> 1.0"}, 41 | {:gettext, "~> 0.18"}, 42 | {:jason, "~> 1.2"}, 43 | {:plug_cowboy, "~> 2.5"}, 44 | {:req, "~> 0.3.0"}, 45 | {:bypass, "~> 2.1"} 46 | ] 47 | end 48 | 49 | # Aliases are shortcuts or tasks specific to the current project. 50 | # For example, to install project dependencies and perform other setup tasks, run: 51 | # 52 | # $ mix setup 53 | # 54 | # See the documentation for `Mix` for more info on aliases. 55 | defp aliases do 56 | [ 57 | setup: ["deps.get"] 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, 3 | "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, 4 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 7 | "finch": {:hex, :finch, "0.13.0", "c881e5460ec563bf02d4f4584079e62201db676ed4c0ef3e59189331c4eddf7b", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49957dcde10dcdc042a123a507a9c5ec5a803f53646d451db2f7dea696fba6cc"}, 8 | "gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"}, 9 | "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, 10 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 11 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 12 | "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, 13 | "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, 14 | "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, 15 | "phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"}, 16 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 17 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, 18 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"}, 19 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 20 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 21 | "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, 22 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 24 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 25 | "req": {:hex, :req, "0.3.0", "45944bfa0ea21294ad269e2025b9983dd084cc89125c4fc0a8de8a4e7869486b", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "1212a3e047eede0fa7eeb84c30d08206d44bb120df98b6f6b9a9e04910954a71"}, 26 | "swoosh": {:hex, :swoosh, "1.7.3", "febb47c8c3ce76747eb9e3ea25ed694c815f72069127e3bb039b7724082ec670", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76abac313f95b6825baa8ceec269d597e8395950c928742fc6451d3456ca256d"}, 27 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 28 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 29 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 30 | } 31 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /priv/setup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metrics ( 2 | project String, 3 | tenant String, 4 | event String, 5 | tags Array(String), 6 | inserted_at timestamp DEFAULT now() 7 | ) ENGINE MergeTree 8 | ORDER BY inserted_at 9 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use VertexWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import VertexWeb.ConnCase 26 | 27 | alias VertexWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint VertexWeb.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _pid} = Vertex.Backend.Testing.start_link() 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/vertex/backends/clickhouse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.ClickhouseTest do 2 | use ExUnit.Case 3 | 4 | alias Plug.Conn 5 | alias Vertex.Metric 6 | alias Vertex.Backend.Clickhouse 7 | 8 | setup do 9 | bypass = Bypass.open(port: 6001) 10 | 11 | %{bypass: bypass} 12 | end 13 | 14 | test "sends a metric to database", %{bypass: bypass} do 15 | Bypass.expect_once(bypass, "POST", "/", fn conn -> 16 | {:ok, body, conn} = Conn.read_body(conn) 17 | 18 | assert conn.query_params["database"] == "analytics" 19 | assert conn.query_params["query"] == "" 20 | 21 | assert body == """ 22 | INSERT INTO metrics (project, tenant, event, tags) 23 | FORMAT Values ('example', '123', 'access.login.success', []) 24 | """ 25 | 26 | Conn.resp(conn, 200, "") 27 | end) 28 | 29 | metric = %Metric{ 30 | project: "example", 31 | tenant: "123", 32 | event: "access.login.success" 33 | } 34 | 35 | assert Clickhouse.record(metric) == :ok 36 | end 37 | 38 | test "sends a metric to database with tags", %{bypass: bypass} do 39 | Bypass.expect_once(bypass, "POST", "/", fn conn -> 40 | {:ok, body, conn} = Conn.read_body(conn) 41 | 42 | assert conn.query_params["database"] == "analytics" 43 | assert conn.query_params["query"] == "" 44 | 45 | assert body == """ 46 | INSERT INTO metrics (project, tenant, event, tags) 47 | FORMAT Values ('example', '123', 'access.login.success', ['test','staging']) 48 | """ 49 | 50 | Conn.resp(conn, 200, "") 51 | end) 52 | 53 | metric = %Metric{ 54 | project: "example", 55 | tenant: "123", 56 | event: "access.login.success", 57 | tags: ["test", "staging"] 58 | } 59 | 60 | assert Clickhouse.record(metric) == :ok 61 | end 62 | 63 | test "sends multiple metrics", %{ bypass: bypass } do 64 | Bypass.expect_once(bypass, "POST", "/", fn conn -> 65 | {:ok, body, conn} = Conn.read_body(conn) 66 | 67 | assert conn.query_params["database"] == "analytics" 68 | assert conn.query_params["query"] == "" 69 | 70 | assert body == """ 71 | INSERT INTO metrics (project, tenant, event, tags) 72 | FORMAT Values ('example', '123', 'login.success', ['test']), 73 | ('foo', '123', 'login.failure', ['test']) 74 | """ 75 | 76 | Conn.resp(conn, 200, "") 77 | end) 78 | 79 | one = %Metric{ 80 | project: "example", 81 | tenant: "123", 82 | event: "login.success", 83 | tags: ["test"] 84 | } 85 | 86 | two = %Metric{ 87 | project: "foo", 88 | tenant: "123", 89 | event: "login.failure", 90 | tags: ["test"] 91 | } 92 | 93 | 94 | assert Clickhouse.record([one, two]) == :ok 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/vertex/backends/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.LoggerTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | 6 | alias Vertex.Metric 7 | alias Vertex.Backend.Logger 8 | 9 | test "sends a metric to stdout" do 10 | metric = %Metric{ 11 | project: "example", 12 | tenant: "123", 13 | event: "login.success", 14 | tags: ["test", "staging"] 15 | } 16 | 17 | assert capture_io(fn -> 18 | :ok = Logger.record(metric) 19 | end) == 20 | ~s|{"event":"login.success","project":"example","tags":["test","staging"],"tenant":"123"}\n| 21 | end 22 | 23 | test "sends multiple metrics to stdout" do 24 | one = %Metric{ 25 | project: "example", 26 | tenant: "123", 27 | event: "login.success", 28 | tags: ["test"] 29 | } 30 | 31 | two = %Metric{ 32 | project: "foo", 33 | tenant: "123", 34 | event: "login.failure", 35 | tags: ["test"] 36 | } 37 | 38 | assert capture_io(fn -> 39 | :ok = Logger.record([one, two]) 40 | end) == """ 41 | {"event":"login.success","project":"example","tags":["test"],"tenant":"123"} 42 | {"event":"login.failure","project":"foo","tags":["test"],"tenant":"123"} 43 | """ 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/vertex/backends/testing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Vertex.Backend.TestingTest do 2 | use ExUnit.Case 3 | 4 | alias Vertex.Metric 5 | alias Vertex.Backend.Testing 6 | 7 | setup do 8 | Testing.reset!() 9 | end 10 | 11 | test "logs a metric" do 12 | metric = %Metric{ 13 | project: "example", 14 | tenant: "123", 15 | event: "access.login.success", 16 | tags: ["test", "staging"] 17 | } 18 | 19 | assert Testing.record(metric) == :ok 20 | assert Testing.metrics() == [metric] 21 | end 22 | 23 | test "logs multiple metric" do 24 | one = %Metric{ 25 | project: "example", 26 | tenant: "123", 27 | event: "login.success", 28 | tags: ["test"] 29 | } 30 | 31 | two = %Metric{ 32 | project: "foo", 33 | tenant: "123", 34 | event: "login.failure", 35 | tags: ["test"] 36 | } 37 | 38 | assert Testing.record([one, two]) == :ok 39 | assert Testing.metrics() == [one, two] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/vertex/project_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Vertex.ProjectTest do 2 | use ExUnit.Case 3 | 4 | alias Vertex.Project 5 | 6 | test "get/1" do 7 | assert Project.get("fake-access-token") == "site1" 8 | refute Project.get("invalie-access-token") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/vertex_web/controllers/event_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Controllers.EventControllerTest do 2 | use VertexWeb.ConnCase 3 | 4 | alias Plug.Conn 5 | alias Vertex.Backend 6 | alias Vertex.Metric 7 | 8 | setup do 9 | Backend.Testing.reset!() 10 | end 11 | 12 | describe "POST /event" do 13 | test "with valid access token", %{conn: conn} do 14 | payload = %{ 15 | tenant: "1234", 16 | event: "login.success", 17 | tags: ["enterprise-plan", "staging"] 18 | } 19 | 20 | conn = 21 | conn 22 | |> Conn.put_req_header("authorization", "Bearer fake-access-token") 23 | |> post("/event", payload) 24 | 25 | assert json_response(conn, 201) == %{} 26 | 27 | # wait for backend to finish recording 28 | Process.sleep(1) 29 | 30 | assert Backend.Testing.metrics() == [ 31 | %Metric{ 32 | project: "site1", 33 | tenant: "1234", 34 | event: "login.success", 35 | tags: ["enterprise-plan", "staging"] 36 | } 37 | ] 38 | end 39 | 40 | test "without access token", %{conn: conn} do 41 | conn = post(conn, "/event", %{}) 42 | 43 | assert json_response(conn, 401) == %{"message" => "invalid access token"} 44 | assert Backend.Testing.metrics() == [] 45 | end 46 | 47 | test "with invalid access token", %{conn: conn} do 48 | conn = 49 | conn 50 | |> Conn.put_req_header("authorization", "Bearer invalid-access-token") 51 | |> post("/event", %{}) 52 | 53 | assert json_response(conn, 401) == %{"message" => "invalid access token"} 54 | assert Backend.Testing.metrics() == [] 55 | end 56 | end 57 | 58 | describe "POST /events" do 59 | test "with valid access token", %{conn: conn} do 60 | payload = Jason.encode!([ 61 | %{ 62 | tenant: "1234", 63 | event: "login.success", 64 | tags: ["plan:enterprise", "staging"] 65 | }, 66 | %{ 67 | tenant: "1234", 68 | event: "login.failure", 69 | tags: ["plan:enterprise", "staging"] 70 | } 71 | ]) 72 | 73 | conn = 74 | conn 75 | |> Conn.put_req_header("authorization", "Bearer fake-access-token") 76 | |> Conn.put_req_header("content-type", "application/json") 77 | |> post("/events", payload) 78 | 79 | assert json_response(conn, 201) == %{} 80 | 81 | # wait for backend to finish recording 82 | Process.sleep(1) 83 | 84 | assert Backend.Testing.metrics() == [ 85 | %Metric{ 86 | project: "site1", 87 | tenant: "1234", 88 | event: "login.success", 89 | tags: ["plan:enterprise", "staging"] 90 | }, 91 | %Metric{ 92 | project: "site1", 93 | tenant: "1234", 94 | event: "login.failure", 95 | tags: ["plan:enterprise", "staging"] 96 | } 97 | ] 98 | end 99 | 100 | test "without access token", %{conn: conn} do 101 | conn = post(conn, "/events", []) 102 | 103 | assert json_response(conn, 401) == %{"message" => "invalid access token"} 104 | assert Backend.Testing.metrics() == [] 105 | end 106 | 107 | test "with invalid access token", %{conn: conn} do 108 | conn = 109 | conn 110 | |> Conn.put_req_header("authorization", "Bearer invalid-access-token") 111 | |> post("/events", []) 112 | 113 | assert json_response(conn, 401) == %{"message" => "invalid access token"} 114 | assert Backend.Testing.metrics() == [] 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/vertex_web/controllers/ping_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.Controllers.PingControllerTest do 2 | use VertexWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/ping") 6 | 7 | assert text_response(conn, 200) == "pong" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/vertex_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VertexWeb.ErrorViewTest do 2 | use VertexWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(VertexWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} 9 | end 10 | 11 | test "renders 500.json" do 12 | assert render(VertexWeb.ErrorView, "500.json", []) == 13 | %{errors: %{detail: "Internal Server Error"}} 14 | end 15 | end 16 | --------------------------------------------------------------------------------