api specs
This commit is contained in:
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
@@ -0,0 +1,221 @@
|
||||
defmodule ShootingEventPhxWeb.ApiController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
alias ShootingEventPhx.{Auth, Events}
|
||||
alias ShootingEventPhx.Auth.SessionStore
|
||||
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
|
||||
alias ShootingEventPhx.AI.Gemini
|
||||
|
||||
def health(conn, _params) do
|
||||
json(conn, %{"status" => "ok"})
|
||||
end
|
||||
|
||||
def state(conn, _params) do
|
||||
state = StateService.read_state(Auth.admin_request?(conn))
|
||||
json(conn, state)
|
||||
end
|
||||
|
||||
def admin_state(conn, _params) do
|
||||
json(conn, StateService.read_state(true))
|
||||
end
|
||||
|
||||
def admin_login(conn, params) do
|
||||
username = Map.get(params, "username", "")
|
||||
password = Map.get(params, "password", "")
|
||||
|
||||
if Auth.verify_admin(username, password) do
|
||||
{:ok, token, expires_at} = SessionStore.create_token()
|
||||
|
||||
json(conn, %{
|
||||
"token" => token,
|
||||
"expiresAt" => DateTime.to_iso8601(expires_at),
|
||||
"username" => Auth.admin_user()
|
||||
})
|
||||
else
|
||||
error(conn, :unauthorized, "invalid credentials")
|
||||
end
|
||||
end
|
||||
|
||||
def admin_logout(conn, _params) do
|
||||
case Auth.bearer_token(conn) do
|
||||
nil -> :ok
|
||||
token -> SessionStore.delete_token(token)
|
||||
end
|
||||
|
||||
json(conn, %{"ok" => true})
|
||||
end
|
||||
|
||||
def update_settings(conn, params) do
|
||||
case Settings.update_settings(params) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
|
||||
{:error, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
end
|
||||
end
|
||||
|
||||
def create_player(conn, params) do
|
||||
case EventData.create_player(params) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
conn |> put_status(:created) |> json(StateService.read_state(true))
|
||||
|
||||
{:error, :bad_request, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
|
||||
{:error, _, message} ->
|
||||
error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_player(conn, %{"id" => id} = params) do
|
||||
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||
result <- EventData.update_player(player_id, params),
|
||||
:ok <- result do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_player(conn, %{"id" => id}) do
|
||||
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||
:ok <- EventData.delete_player(player_id) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def auto_group_players(conn, params) do
|
||||
groups = Map.get(params, "groups", [])
|
||||
|
||||
case EventData.auto_group_players(groups) do
|
||||
:ok ->
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
|
||||
{:error, :bad_request, message} ->
|
||||
error(conn, :bad_request, message)
|
||||
|
||||
{:error, _, message} ->
|
||||
error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
score <- parse_score(Map.get(params, "score")),
|
||||
:ok <- EventData.update_score(normalized_stage, player_id, score) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:invalid_score, message} -> error(conn, :bad_request, message)
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_stage(conn, %{"stage" => stage} = params) do
|
||||
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
|
||||
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
|
||||
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
|
||||
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
nil -> error(conn, :bad_request, "imageData is required")
|
||||
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
|
||||
Events.broadcast()
|
||||
json(conn, StateService.read_state(true))
|
||||
else
|
||||
{:error, message} -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||
end
|
||||
end
|
||||
|
||||
def score_advice(conn, %{"stage" => stage, "id" => id}) do
|
||||
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
|
||||
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
|
||||
{:ok, advice} <-
|
||||
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
|
||||
json(conn, advice)
|
||||
else
|
||||
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
|
||||
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
|
||||
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
|
||||
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
|
||||
:error -> error(conn, :bad_request, "invalid player id")
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_score(value) do
|
||||
cond do
|
||||
is_integer(value) ->
|
||||
value
|
||||
|
||||
is_binary(value) ->
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{v, ""} -> v
|
||||
_ -> {:invalid_score, "invalid request body"}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:invalid_score, "invalid request body"}
|
||||
end
|
||||
end
|
||||
|
||||
defp truthy?(value) do
|
||||
case value do
|
||||
true -> true
|
||||
1 -> true
|
||||
"1" -> true
|
||||
"true" -> true
|
||||
"yes" -> true
|
||||
"on" -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp error(conn, status, message) do
|
||||
conn
|
||||
|> put_status(status)
|
||||
|> json(%{"message" => message})
|
||||
end
|
||||
end
|
||||
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule ShootingEventPhxWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,40 @@
|
||||
defmodule ShootingEventPhxWeb.EventsController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
alias ShootingEventPhx.Events
|
||||
|
||||
def stream(conn, _params) do
|
||||
conn =
|
||||
conn
|
||||
|> put_resp_header("content-type", "text/event-stream")
|
||||
|> put_resp_header("cache-control", "no-cache, no-transform")
|
||||
|> put_resp_header("connection", "keep-alive")
|
||||
|> put_resp_header("x-accel-buffering", "no")
|
||||
|> send_chunked(200)
|
||||
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
|
||||
|
||||
:ok = Events.subscribe()
|
||||
loop_stream(conn)
|
||||
end
|
||||
|
||||
defp loop_stream(conn) do
|
||||
receive do
|
||||
{:state, ts} ->
|
||||
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
|
||||
{:ok, conn} -> loop_stream(conn)
|
||||
{:error, :closed} -> conn
|
||||
{:error, _} -> conn
|
||||
end
|
||||
after
|
||||
20_000 ->
|
||||
now_unix = DateTime.utc_now() |> DateTime.to_unix()
|
||||
|
||||
case chunk(conn, ": ping #{now_unix}\n\n") do
|
||||
{:ok, conn} -> loop_stream(conn)
|
||||
{:error, :closed} -> conn
|
||||
{:error, _} -> conn
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
defmodule ShootingEventPhxWeb.FrontendController do
|
||||
use ShootingEventPhxWeb, :controller
|
||||
|
||||
def serve(conn, %{"path" => segments}) do
|
||||
request_path =
|
||||
segments
|
||||
|> List.wrap()
|
||||
|> Enum.join("/")
|
||||
|> String.trim_leading("/")
|
||||
|
||||
if String.starts_with?(request_path, "api") do
|
||||
send_resp(conn, 404, "Not Found")
|
||||
else
|
||||
web_dir = web_dir()
|
||||
requested = if request_path == "", do: "index.html", else: request_path
|
||||
requested_file = safe_file_path(web_dir, requested)
|
||||
|
||||
cond do
|
||||
requested_file != nil and File.regular?(requested_file) ->
|
||||
send_static_file(conn, requested_file)
|
||||
|
||||
File.regular?(Path.join(web_dir, "index.html")) ->
|
||||
send_static_file(conn, Path.join(web_dir, "index.html"))
|
||||
|
||||
true ->
|
||||
send_resp(conn, 404, "frontend build not found. run make build")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp web_dir do
|
||||
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
|
||||
end
|
||||
|
||||
defp safe_file_path(web_dir, request_path) do
|
||||
cleaned = request_path |> Path.expand(web_dir)
|
||||
root = Path.expand(web_dir)
|
||||
|
||||
if String.starts_with?(cleaned, root) do
|
||||
cleaned
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp send_static_file(conn, file_path) do
|
||||
content_type =
|
||||
case MIME.from_path(file_path) do
|
||||
"" -> "application/octet-stream"
|
||||
type -> type
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(content_type)
|
||||
|> send_file(200, file_path)
|
||||
end
|
||||
end
|
||||
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule ShootingEventPhxWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :shooting_event_phx
|
||||
|
||||
# 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: "_shooting_event_phx_key",
|
||||
signing_salt: "RrxJbij4",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# socket "/live", Phoenix.LiveView.Socket,
|
||||
# websocket: [connect_info: [session: @session_options]],
|
||||
# longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# When code reloading is disabled (e.g., in production),
|
||||
# the `gzip` option is enabled to serve compressed
|
||||
# static files generated by running `phx.digest`.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :shooting_event_phx,
|
||||
gzip: not code_reloading?,
|
||||
only: ShootingEventPhxWeb.static_paths(),
|
||||
raise_on_missing_only: code_reloading?
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shooting_event_phx
|
||||
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 ShootingEventPhxWeb.Plugs.CORS
|
||||
plug Plug.Session, @session_options
|
||||
plug ShootingEventPhxWeb.Router
|
||||
end
|
||||
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule ShootingEventPhxWeb.Plugs.AdminOnly do
|
||||
@moduledoc false
|
||||
import Plug.Conn
|
||||
alias ShootingEventPhx.Auth
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if Auth.admin_request?(conn) do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> Phoenix.Controller.json(%{"message" => "invalid or expired admin token"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule ShootingEventPhxWeb.Plugs.CORS do
|
||||
@moduledoc false
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
conn =
|
||||
conn
|
||||
|> put_resp_header("access-control-allow-origin", "*")
|
||||
|> put_resp_header("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
|> put_resp_header("access-control-allow-headers", "content-type,authorization")
|
||||
|
||||
if conn.method == "OPTIONS" do
|
||||
conn |> send_resp(204, "") |> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule ShootingEventPhxWeb.Router do
|
||||
use ShootingEventPhxWeb, :router
|
||||
|
||||
pipeline :api_json do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
pipeline :frontend do
|
||||
end
|
||||
|
||||
pipeline :admin_api do
|
||||
plug ShootingEventPhxWeb.Plugs.AdminOnly
|
||||
end
|
||||
|
||||
scope "/api", ShootingEventPhxWeb do
|
||||
get "/events", EventsController, :stream
|
||||
end
|
||||
|
||||
scope "/api", ShootingEventPhxWeb do
|
||||
pipe_through :api_json
|
||||
|
||||
get "/health", ApiController, :health
|
||||
get "/state", ApiController, :state
|
||||
|
||||
post "/admin/login", ApiController, :admin_login
|
||||
|
||||
scope "/admin" do
|
||||
pipe_through :admin_api
|
||||
|
||||
post "/logout", ApiController, :admin_logout
|
||||
get "/state", ApiController, :admin_state
|
||||
put "/settings", ApiController, :update_settings
|
||||
post "/players", ApiController, :create_player
|
||||
post "/players/auto-group", ApiController, :auto_group_players
|
||||
put "/players/:id", ApiController, :update_player
|
||||
delete "/players/:id", ApiController, :delete_player
|
||||
put "/scores/:stage/:id", ApiController, :update_score
|
||||
put "/scores/:stage/:id/proof", ApiController, :update_score_proof
|
||||
delete "/scores/:stage/:id/proof", ApiController, :delete_score_proof
|
||||
post "/scores/:stage/:id/advice", ApiController, :score_advice
|
||||
post "/scores/:stage/reset", ApiController, :reset_stage
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", ShootingEventPhxWeb do
|
||||
pipe_through :frontend
|
||||
match :*, "/*path", FrontendController, :serve
|
||||
end
|
||||
end
|
||||
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
@@ -0,0 +1,93 @@
|
||||
defmodule ShootingEventPhxWeb.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}
|
||||
),
|
||||
sum("phoenix.socket_drain.count"),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("shooting_event_phx.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("shooting_event_phx.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("shooting_event_phx.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("shooting_event_phx.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("shooting_event_phx.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# 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.
|
||||
# {ShootingEventPhxWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user