diff --git a/examples/roll_dice/.formatter.exs b/examples/roll_dice/.formatter.exs new file mode 100644 index 0000000..4761678 --- /dev/null +++ b/examples/roll_dice/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:phoenix], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/examples/roll_dice/.gitignore b/examples/roll_dice/.gitignore new file mode 100644 index 0000000..1c80add --- /dev/null +++ b/examples/roll_dice/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +roll_dice-*.tar + diff --git a/examples/roll_dice/README.md b/examples/roll_dice/README.md new file mode 100644 index 0000000..04e761a --- /dev/null +++ b/examples/roll_dice/README.md @@ -0,0 +1,84 @@ +# RollDice + +The following example uses a basic [Phoenix](https://www.phoenixframework.org/) +web application. For more elaborate examples, see [examples](/docs/instrumentation/erlang/examples/). + +To start your Phoenix server: + + * Run `mix setup` to install and setup dependencies + * Start the opentelemetry collector and Jaeger front-end with `docker-compose up -d` + * Set your service name `export OTEL_SERVICE_NAME="roll_dice"` + * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +To generate and view traces: + + * Visit the new API endpoint with `curl http://localhost:4000/api/rolldice` or in your browser + * Visit [`localhost:16686`](http://localhost:16686) to see the traces in Jaeger + +For more information on the OpenTelemetry instrumentation in this example, check out the guide for getting started with [OpenTelemetry](https://opentelemetry.io/docs/instrumentation/erlang/getting-started/) + +## How did we get here? + +To begin, use the `phx` generator to create a new project. To keep things simple we'll leave out the database and html for now with the `--no-ecto` and `--no-html` flags. +In a real app, you'll probably want to include ecto configured for your preferred database with the `--database` flag (`postgres` by default). + +```shell +mix phx.new --no-ecto --no-html roll_dice +``` + +### Rolling The Dice + +Now we'll add an API endpoint that will let us roll the dice and return a random +number between 1 and 6. + +To start, we'll add a route to the `/api` scope in your router + +```elixir +# lib/roll_dice_web/router.ex +scope "/api", DiceGameWeb do + pipe_through :api + + get "/rolldice", DiceController, :roll +end +``` + +Then we'll create a new file in the controllers folder for that module. We told +the router that we will define a roll function, so we'll do that. It will return +a `200` response code and the result of a `dice_roll` function, which we will +emit a span for. We also want to set the value of the generated roll as an +attribute on the span. + +```elixir +# lib/roll_dice_web/controllers/dice_controller.ex +defmodule DiceGameWeb.DiceController do + use DiceGameWeb, :controller + require OpenTelemetry.Tracer, as: Tracer + + def roll(conn, _params) do + send_resp(conn, 200, roll_dice()) + end + + defp roll_dice do + Tracer.with_span("roll_dice") do + roll = Enum.random(1..6) + + Tracer.set_attribute("roll.value", roll) + + to_string(roll) + end + end +end + +``` + +If you point your browser/curl/etc. to [`localhost:4000/api/rolldice`](http://localhost:4000/api/rolldice) you should get a random number in response. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/examples/roll_dice/config/config.exs b/examples/roll_dice/config/config.exs new file mode 100644 index 0000000..1cdc9bd --- /dev/null +++ b/examples/roll_dice/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +# Configures the endpoint +config :roll_dice, RollDiceWeb.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [json: RollDiceWeb.ErrorJSON], + layout: false + ], + pubsub_server: RollDice.PubSub, + live_view: [signing_salt: "F/DlUt0K"] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/examples/roll_dice/config/dev.exs b/examples/roll_dice/config/dev.exs new file mode 100644 index 0000000..8254ab7 --- /dev/null +++ b/examples/roll_dice/config/dev.exs @@ -0,0 +1,53 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :roll_dice, RollDiceWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "4zhqnz+sDpekY/MD3u0QNJpzg8PRWarEI0u+NfSqzwtKp/byfGUWwka4ALZ36yqz", + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Enable dev routes for dashboard and mailbox +config :roll_dice, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/examples/roll_dice/config/prod.exs b/examples/roll_dice/config/prod.exs new file mode 100644 index 0000000..1fe2d9e --- /dev/null +++ b/examples/roll_dice/config/prod.exs @@ -0,0 +1,7 @@ +import Config + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/examples/roll_dice/config/runtime.exs b/examples/roll_dice/config/runtime.exs new file mode 100644 index 0000000..77074a3 --- /dev/null +++ b/examples/roll_dice/config/runtime.exs @@ -0,0 +1,91 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/roll_dice start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :roll_dice, RollDiceWeb.Endpoint, server: true +end + +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :roll_dice, RollDiceWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + config :opentelemetry, + span_processor: :batch, + traces_exporter: :otlp + + config :opentelemetry_exporter, + otlp_protocol: :http_protobuf, + otlp_endpoint: "http://localhost:4317" + + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :roll_dice, RollDiceWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your endpoint, ensuring + # no data is ever sent via http, always redirecting to https: + # + # config :roll_dice, RollDiceWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. +end diff --git a/examples/roll_dice/config/test.exs b/examples/roll_dice/config/test.exs new file mode 100644 index 0000000..8b6a314 --- /dev/null +++ b/examples/roll_dice/config/test.exs @@ -0,0 +1,14 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :roll_dice, RollDiceWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "ZclS6l1Z5RRwC1QKG8uIr4zgwQGkUYMB1AARGv3iaiC4GGRbATFBgKGKURgWPAMa", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/examples/roll_dice/docker-compose.yml b/examples/roll_dice/docker-compose.yml new file mode 100644 index 0000000..ed5d785 --- /dev/null +++ b/examples/roll_dice/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3" +services: + otel: + image: otel/opentelemetry-collector-contrib:0.76.1 + command: ["--config=/conf/otel-collector-config.yaml"] + privileged: true + ports: + - 4317:4317 + - 4318:4318 + - 55679:55679 + volumes: + - ./otel-collector-config.yaml:/conf/otel-collector-config.yaml + links: + - jaeger-all-in-one + + jaeger-all-in-one: + image: jaegertracing/all-in-one:1.45 + restart: always + environment: + COLLECTOR_OTLP_ENABLED: true + ports: + - "16686:16686" + - "4317" diff --git a/examples/roll_dice/lib/roll_dice.ex b/examples/roll_dice/lib/roll_dice.ex new file mode 100644 index 0000000..0db696a --- /dev/null +++ b/examples/roll_dice/lib/roll_dice.ex @@ -0,0 +1,9 @@ +defmodule RollDice do + @moduledoc """ + RollDice keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/examples/roll_dice/lib/roll_dice/application.ex b/examples/roll_dice/lib/roll_dice/application.ex new file mode 100644 index 0000000..7ee347a --- /dev/null +++ b/examples/roll_dice/lib/roll_dice/application.ex @@ -0,0 +1,37 @@ +defmodule RollDice.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + :opentelemetry_cowboy.setup() + OpentelemetryPhoenix.setup(adapter: :cowboy2) + + children = [ + # Start the Telemetry supervisor + RollDiceWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: RollDice.PubSub}, + # Start the Endpoint (http/https) + RollDiceWeb.Endpoint + # Start a worker by calling: RollDice.Worker.start_link(arg) + # {RollDice.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: RollDice.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + RollDiceWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/examples/roll_dice/lib/roll_dice_web.ex b/examples/roll_dice/lib/roll_dice_web.ex new file mode 100644 index 0000000..de1af22 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web.ex @@ -0,0 +1,66 @@ +defmodule RollDiceWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use RollDiceWeb, :controller + use RollDiceWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: RollDiceWeb.Layouts] + + import Plug.Conn + import RollDiceWeb.Gettext + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: RollDiceWeb.Endpoint, + router: RollDiceWeb.Router, + statics: RollDiceWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/examples/roll_dice/lib/roll_dice_web/controllers/dice_controller.ex b/examples/roll_dice/lib/roll_dice_web/controllers/dice_controller.ex new file mode 100644 index 0000000..a2f4e61 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/controllers/dice_controller.ex @@ -0,0 +1,18 @@ +defmodule RollDiceWeb.DiceController do + use RollDiceWeb, :controller + require OpenTelemetry.Tracer, as: Tracer + + def roll(conn, _params) do + send_resp(conn, 200, roll_dice()) + end + + defp roll_dice do + Tracer.with_span "roll_dice" do + roll = Enum.random(1..6) + + Tracer.set_attribute("roll.value", roll) + + to_string(roll) + end + end +end diff --git a/examples/roll_dice/lib/roll_dice_web/controllers/error_json.ex b/examples/roll_dice/lib/roll_dice_web/controllers/error_json.ex new file mode 100644 index 0000000..8e50889 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/controllers/error_json.ex @@ -0,0 +1,15 @@ +defmodule RollDiceWeb.ErrorJSON do + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/examples/roll_dice/lib/roll_dice_web/endpoint.ex b/examples/roll_dice/lib/roll_dice_web/endpoint.ex new file mode 100644 index 0000000..e59df0a --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/endpoint.ex @@ -0,0 +1,44 @@ +defmodule RollDiceWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :roll_dice + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_roll_dice_key", + signing_salt: "A5RXaafk", + same_site: "Lax" + ] + + # socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :roll_dice, + gzip: false, + only: RollDiceWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug RollDiceWeb.Router +end diff --git a/examples/roll_dice/lib/roll_dice_web/gettext.ex b/examples/roll_dice/lib/roll_dice_web/gettext.ex new file mode 100644 index 0000000..af0ce56 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule RollDiceWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import RollDiceWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :roll_dice +end diff --git a/examples/roll_dice/lib/roll_dice_web/router.ex b/examples/roll_dice/lib/roll_dice_web/router.ex new file mode 100644 index 0000000..ccbe751 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/router.ex @@ -0,0 +1,13 @@ +defmodule RollDiceWeb.Router do + use RollDiceWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", RollDiceWeb do + pipe_through :api + + get "/rolldice", DiceController, :roll + end +end diff --git a/examples/roll_dice/lib/roll_dice_web/telemetry.ex b/examples/roll_dice/lib/roll_dice_web/telemetry.ex new file mode 100644 index 0000000..111d9b5 --- /dev/null +++ b/examples/roll_dice/lib/roll_dice_web/telemetry.ex @@ -0,0 +1,69 @@ +defmodule RollDiceWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {RollDiceWeb, :count_users, []} + ] + end +end diff --git a/examples/roll_dice/mix.exs b/examples/roll_dice/mix.exs new file mode 100644 index 0000000..6e20181 --- /dev/null +++ b/examples/roll_dice/mix.exs @@ -0,0 +1,65 @@ +defmodule RollDice.MixProject do + use Mix.Project + + def project do + [ + app: :roll_dice, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + releases: [ + roll_dice: [ + applications: [opentelemetry_exporter: :permanent, opentelemetry: :temporary] + ] + ] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {RollDice.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.7"}, + {:telemetry_metrics, "~> 0.6"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.20"}, + {:jason, "~> 1.2"}, + {:plug_cowboy, "~> 2.5"}, + {:opentelemetry_exporter, "~> 1.5"}, + {:opentelemetry, "~> 1.3"}, + {:opentelemetry_api, "~> 1.2"}, + {:opentelemetry_phoenix, "~> 1.1"}, + {:opentelemetry_cowboy, "~> 0.2"}, + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get"] + ] + end +end diff --git a/examples/roll_dice/mix.lock b/examples/roll_dice/mix.lock new file mode 100644 index 0000000..2b1eca7 --- /dev/null +++ b/examples/roll_dice/mix.lock @@ -0,0 +1,40 @@ +%{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"}, + "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, + "chatterbox": {:hex, :ts_chatterbox, "0.13.0", "6f059d97bcaa758b8ea6fffe2b3b81362bd06b639d3ea2bb088335511d691ebf", [:rebar3], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "b93d19104d86af0b3f2566c4cba2a57d2e06d103728246ba1ac6c3c0ff010aa7"}, + "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, + "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"}, + "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, + "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, + "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, + "gproc": {:hex, :gproc, "0.8.0", "cea02c578589c61e5341fce149ea36ccef236cc2ecac8691fba408e7ea77ec2f", [:rebar3], [], "hexpm", "580adafa56463b75263ef5a5df4c86af321f68694e7786cb057fd805d1e2a7de"}, + "grpcbox": {:hex, :grpcbox, "0.16.0", "b83f37c62d6eeca347b77f9b1ec7e9f62231690cdfeb3a31be07cd4002ba9c82", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.13.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "294df743ae20a7e030889f00644001370a4f7ce0121f3bbdaf13cf3169c62913"}, + "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, + "opentelemetry": {:hex, :opentelemetry, "1.3.1", "f0a342a74379e3540a634e7047967733da4bc8b873ec9026e224b2bd7369b1fc", [:rebar3], [{:opentelemetry_api, "~> 1.2.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "de476b2ac4faad3e3fe3d6e18b35dec9cb338c3b9910c2ce9317836dacad3483"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.2.2", "693f47b0d8c76da2095fe858204cfd6350c27fe85d00e4b763deecc9588cf27a", [:mix, :rebar3], [{:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}], "hexpm", "dc77b9a00f137a858e60a852f14007bb66eda1ffbeb6c05d5fe6c9e678b05e9d"}, + "opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "0.2.1", "feb09d4abe48c6d983fd46ea7b500cdf31b0f77c80702e175fe1fd86f8a52445", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.0", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21ba198dd51294211a498dee720a30d2c2cb4d35ddc843d84f2d4e0a9681be49"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.6.0", "f4fbf69aa9f1541b253813221b82b48a9863bc1570d8ecc517bc510c0d1d3d8c", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.3", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "1802d1dca297e46f21e5832ecf843c451121e875f73f04db87355a6cb2ba1710"}, + "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "1.1.1", "b6ab632d39138c2cc9b6e52b65d560545904f659c42647856d669e593110521f", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.0", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "942850ce28fe21f98d98a743b94163820ce5ba6488333a806dbd1e8161a653d8"}, + "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.2.2", "85244a49f0c32ae1e2f3d58c477c265bd6125ee3480ade82b0fa9324b85ed3f0", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "04db13302a34bea8350a13ed9d49c22dfd32c4bc590d8aa88b6b4b7e4f346c61"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.0.0", "d5982a319e725fcd2305b306b65c18a86afdcf7d96821473cf0649ff88877615", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.0", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "3401d13a1d4b7aa941a77e6b3ec074f0ae77f83b5b2206766ce630123a9291a9"}, + "phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [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]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, + "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.20.0", "1ac0c53f95e201feb8d398ef9d764ae74175231289d89f166ba88a7f50cd8e73", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ab57b74b1a63dc5775650699a3ec032ec0065005eff1f020818742b7312a8426"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, +} diff --git a/examples/roll_dice/otel-collector-config.yaml b/examples/roll_dice/otel-collector-config.yaml new file mode 100644 index 0000000..d460516 --- /dev/null +++ b/examples/roll_dice/otel-collector-config.yaml @@ -0,0 +1,47 @@ +# OpenTelemetry Collector config that receives OTLP and exports to Zipkin +receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + cors: + allowed_origins: + - "http://localhost:4000" + endpoint: "0.0.0.0:4318" +processors: + batch: + send_batch_size: 1024 + timeout: 5s +exporters: + otlp: + endpoint: jaeger-all-in-one:4317 + tls: + insecure: true + + logging: + loglevel: debug + sampling_initial: 1 + sampling_thereafter: 1 + +extensions: + zpages: {} + +service: + telemetry: + logs: + level: "debug" + extensions: [zpages] + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [logging, otlp] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [logging] + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] diff --git a/examples/roll_dice/priv/gettext/en/LC_MESSAGES/errors.po b/examples/roll_dice/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/examples/roll_dice/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/examples/roll_dice/priv/gettext/errors.pot b/examples/roll_dice/priv/gettext/errors.pot new file mode 100644 index 0000000..d6f47fa --- /dev/null +++ b/examples/roll_dice/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + diff --git a/examples/roll_dice/priv/static/favicon.ico b/examples/roll_dice/priv/static/favicon.ico new file mode 100644 index 0000000..73de524 Binary files /dev/null and b/examples/roll_dice/priv/static/favicon.ico differ diff --git a/examples/roll_dice/priv/static/robots.txt b/examples/roll_dice/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/examples/roll_dice/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/examples/roll_dice/test/roll_dice_web/controllers/error_json_test.exs b/examples/roll_dice/test/roll_dice_web/controllers/error_json_test.exs new file mode 100644 index 0000000..b0a9fb1 --- /dev/null +++ b/examples/roll_dice/test/roll_dice_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule RollDiceWeb.ErrorJSONTest do + use RollDiceWeb.ConnCase, async: true + + test "renders 404" do + assert RollDiceWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert RollDiceWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/examples/roll_dice/test/support/conn_case.ex b/examples/roll_dice/test/support/conn_case.ex new file mode 100644 index 0000000..1f12f5f --- /dev/null +++ b/examples/roll_dice/test/support/conn_case.ex @@ -0,0 +1,37 @@ +defmodule RollDiceWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use RollDiceWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint RollDiceWeb.Endpoint + + use RollDiceWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import RollDiceWeb.ConnCase + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/examples/roll_dice/test/test_helper.exs b/examples/roll_dice/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/examples/roll_dice/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()