├── config ├── dev.exs ├── prod.exs ├── test.exs └── config.exs ├── lib ├── scout_apm │ ├── tracing │ │ ├── annotations.ex │ │ ├── transaction.ex │ │ └── timing.ex │ ├── collector.ex │ ├── devtrace.ex │ ├── utils.ex │ ├── instrumentation.ex │ ├── config │ │ ├── null.ex │ │ ├── application.ex │ │ ├── defaults.ex │ │ ├── env.ex │ │ └── coercions.ex │ ├── instruments │ │ ├── samplers │ │ │ └── memory.ex │ │ ├── exs_engine.ex │ │ ├── ecto_telemetry.ex │ │ ├── eex_engine.ex │ │ ├── slime_engine.ex │ │ └── ecto_logger.ex │ ├── agent_note.ex │ ├── payload │ │ ├── context.ex │ │ ├── metadata.ex │ │ ├── slow_transaction.ex │ │ └── metric.ex │ ├── direct_analysis_store.ex │ ├── application.ex │ ├── scope_stack.ex │ ├── context.ex │ ├── watcher.ex │ ├── config.ex │ ├── persistent_histogram.ex │ ├── internal │ │ ├── context.ex │ │ ├── duration.ex │ │ ├── metric.ex │ │ ├── layer.ex │ │ ├── job_record.ex │ │ └── web_trace.ex │ ├── logger.ex │ ├── cache.ex │ ├── core │ │ ├── manifest.ex │ │ └── agent_manager.ex │ ├── core.ex │ ├── devtrace │ │ └── plug.ex │ ├── plugs │ │ └── controller_timer.ex │ ├── scored_item_set.ex │ ├── metric_set.ex │ ├── tracked_request.ex │ ├── commands.ex │ └── tracing.ex ├── scout_apm.ex └── mix │ └── tasks │ └── scout.test_config.ex ├── test ├── support │ ├── templates │ │ ├── simple.html.eex │ │ └── simple.json.exs │ ├── test_plug_app.ex │ ├── test_collector.ex │ └── test_tracing.ex ├── test_helper.exs ├── scout_apm_test.exs └── scout_apm │ ├── internal │ └── job_record_test.exs │ ├── instruments │ ├── ecto_telemetry_test.exs │ ├── eex_engine_test.exs │ ├── exs_engine_test.exs │ └── ecto_logger_test.exs │ ├── payload │ └── metadata_test.exs │ ├── config_test.exs │ ├── cache_test.exs │ ├── config │ └── coercion_test.exs │ ├── logger_test.exs │ ├── metric_set_test.exs │ ├── tracked_request_test.exs │ ├── scored_item_set_test.exs │ ├── tracing_test.exs │ └── plugs │ └── controller_timer_test.exs ├── .formatter.exs ├── .gitignore ├── .travis.yml ├── priv └── static │ └── devtrace │ └── xml_http_script.html ├── README.md ├── mix.exs ├── CHANGELOG.md ├── LICENSE.md └── mix.lock /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /lib/scout_apm/tracing/annotations.ex: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/support/templates/simple.html.eex: -------------------------------------------------------------------------------- 1 |

Hello

2 | -------------------------------------------------------------------------------- /test/support/templates/simple.json.exs: -------------------------------------------------------------------------------- 1 | %{foo: "bar"} 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_started(:telemetry) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /lib/scout_apm/collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Collector do 2 | @callback send(map()) :: :ok 3 | end 4 | -------------------------------------------------------------------------------- /test/scout_apm_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApmTest do 2 | use ExUnit.Case 3 | doctest ScoutApm 4 | end 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /lib/scout_apm.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm do 2 | @moduledoc """ 3 | Documentation for ScoutApm. 4 | """ 5 | end 6 | -------------------------------------------------------------------------------- /lib/scout_apm/devtrace.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.DevTrace do 2 | def enabled? do 3 | ScoutApm.Config.find(:dev_trace) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, backends: [] 4 | 5 | config :scout_apm, 6 | collector_module: ScoutApm.TestCollector 7 | -------------------------------------------------------------------------------- /test/scout_apm/internal/job_record_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.JobRecordTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "new/0" do 5 | test "creating" do 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/scout_apm/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Utils do 2 | def random_string(length) do 3 | length 4 | |> :crypto.strong_rand_bytes() 5 | |> Base.url_encode64() 6 | |> binary_part(0, length) 7 | end 8 | 9 | def agent_version do 10 | Application.spec(:scout_apm, :vsn) |> to_string 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/scout_apm/instrumentation.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instrumentation do 2 | defmacro __using__(opts) do 3 | timer_options = Keyword.take(opts, [:include_application_name]) 4 | 5 | quote do 6 | plug(ScoutApm.DevTrace.Plug) 7 | plug(ScoutApm.Plugs.ControllerTimer, unquote(timer_options)) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/scout_apm/config/null.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Config.Null do 2 | @moduledoc """ 3 | Always says it contains key, and the value is always nil 4 | """ 5 | 6 | def load do 7 | :null 8 | end 9 | 10 | def contains?(_data, _key) do 11 | true 12 | end 13 | 14 | def lookup(_data, _key) do 15 | nil 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/samplers/memory.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.Samplers.Memory do 2 | def metrics do 3 | [ 4 | ScoutApm.Internal.Metric.from_sampler_value("Memory", "Physical", total_mb()) 5 | ] 6 | end 7 | 8 | def total_bytes do 9 | :erlang.memory(:total) 10 | end 11 | 12 | def total_mb do 13 | total_bytes() / 1024.0 / 1024.0 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/scout_apm/instruments/ecto_telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.EctoTelemetryTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "attach/1" do 5 | test "can attach multiple times" do 6 | assert :ok = ScoutApm.Instruments.EctoTelemetry.attach(MyApp.RepoA) 7 | assert :ok = ScoutApm.Instruments.EctoTelemetry.attach(MyApp.RepoB) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/exs_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.ExsEngine do 2 | @behaviour Phoenix.Template.Engine 3 | 4 | # TODO: Make this name correctly for other template locations 5 | def compile(path, name) do 6 | quoted_template = Phoenix.Template.ExsEngine.compile(path, name) 7 | 8 | quote do 9 | require ScoutApm.Tracing 10 | ScoutApm.Tracing.timing("Exs", unquote(path), do: unquote(quoted_template)) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/scout_apm/agent_note.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.AgentNote do 2 | @moduledoc """ 3 | A centralized place to log & note any agent misconfigurations, interesting 4 | occurances, or other things that'd normally be log messages. 5 | """ 6 | 7 | def note({:metric_type, :over_limit, max_types}) do 8 | ScoutApm.Logger.log( 9 | :info, 10 | "Skipping absorbing metric, over limit of #{max_types} unique metric types. See http://docs.scoutapm.com/#elixir-agent for more details" 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/scout_apm/payload/context.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Payload.Context do 2 | @moduledoc """ 3 | Converts a list of ScoutApm.Internal.Context types into an appropriate data 4 | structure to serialize via Jason.encode! 5 | """ 6 | 7 | def new(contexts) do 8 | Map.merge( 9 | contexts_of_type(contexts, :extra), 10 | %{user: contexts_of_type(contexts, :user)} 11 | ) 12 | end 13 | 14 | defp contexts_of_type(contexts, type) do 15 | contexts 16 | |> Enum.group_by(fn c -> c.type end) 17 | |> Map.get(type, []) 18 | |> Enum.map(fn c -> {c.key, c.value} end) 19 | |> Enum.into(%{}) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/scout_apm/config/application.ex: -------------------------------------------------------------------------------- 1 | # Reads values set in mix configuration 2 | # 3 | # Supports the {:system, "MY_ENV_VAR"} syntax, in the same manner as many other libraries 4 | defmodule ScoutApm.Config.Application do 5 | def load do 6 | :no_data 7 | end 8 | 9 | def contains?(_data, key) do 10 | Application.get_env(:scout_apm, key) != nil 11 | end 12 | 13 | def lookup(_data, key) do 14 | Application.get_env(:scout_apm, key) |> resolve 15 | end 16 | 17 | # Takes "raw" values from various config sources, and if those values are 18 | # in {:system, "VAR_NAME"} format it loads VAR_NAME value from ENV 19 | defp resolve({:system, value}), do: System.get_env(value) 20 | defp resolve(value), do: value 21 | end 22 | -------------------------------------------------------------------------------- /lib/scout_apm/tracing/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Tracing.Annotations.Transaction do 2 | @moduledoc false 3 | 4 | defstruct function_name: nil, 5 | scout_name: nil, 6 | # can be :web or :background 7 | type: :web, 8 | args: nil, 9 | guards: nil, 10 | body: nil 11 | 12 | def new(type, mod, fun, args, guards, body, opts \\ []) do 13 | %__MODULE__{ 14 | type: type, 15 | scout_name: opts[:name] || default_scout_name(mod, fun), 16 | function_name: fun, 17 | args: args, 18 | guards: guards, 19 | body: body 20 | } 21 | end 22 | 23 | defp default_scout_name(mod, fun) do 24 | "#{mod}.#{fun}" |> String.replace_prefix("Elixir.", "") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/scout_apm/payload/metadata_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Payload.MetadataTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ScoutApm.Payload.Metadata 5 | 6 | describe "new/1" do 7 | test "creating a metadata struct with a timestamp" do 8 | {:ok, app_root} = File.cwd() 9 | {:ok, timestamp} = NaiveDateTime.new(2018, 1, 1, 1, 5, 3) 10 | agent_version = Application.spec(:scout_apm)[:vsn] |> to_string() 11 | 12 | assert %ScoutApm.Payload.Metadata{ 13 | agent_version: ^agent_version, 14 | app_root: ^app_root, 15 | payload_version: 1, 16 | agent_time: "2018-01-01T01:05:03Z", 17 | platform: "elixir" 18 | } = Metadata.new(timestamp) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/scout_apm/instruments/eex_engine_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.EExEngineTest do 2 | use ExUnit.Case 3 | 4 | defmodule EExView do 5 | use Phoenix.Template, 6 | root: "./test/support/templates", 7 | template_engines: %{ 8 | eex: ScoutApm.Instruments.EExEngine 9 | } 10 | 11 | def render(template, assigns) do 12 | render_template(template, assigns) 13 | end 14 | end 15 | 16 | describe "compile/2" do 17 | test "can compile" do 18 | ScoutApm.Instruments.EExEngine.compile( 19 | "./test/support/templates/simple.html.eex", 20 | "simple.html" 21 | ) 22 | end 23 | end 24 | 25 | describe "render/2" do 26 | test "can render" do 27 | assert EExView.render("test/support/templates/simple.html", %{}) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/scout_apm/instruments/exs_engine_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.ExsEngineTest do 2 | use ExUnit.Case 3 | 4 | defmodule ExsView do 5 | use Phoenix.Template, 6 | root: "./test/support/templates", 7 | template_engines: %{ 8 | exs: ScoutApm.Instruments.ExsEngine 9 | } 10 | 11 | def render(template, assigns) do 12 | render_template(template, assigns) 13 | end 14 | end 15 | 16 | describe "compile/2" do 17 | test "can compile" do 18 | ScoutApm.Instruments.ExsEngine.compile( 19 | "./test/support/templates/simple.json.exs", 20 | "simple.json" 21 | ) 22 | end 23 | end 24 | 25 | describe "render/2" do 26 | test "can render" do 27 | assert ExsView.render("test/support/templates/simple.json", %{}) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/scout_apm/tracing/timing.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Tracing.Annotations.Timing do 2 | @moduledoc false 3 | 4 | defstruct function_name: nil, 5 | args: nil, 6 | guards: nil, 7 | body: nil, 8 | # The public-facing API calls "type" "category". This is because we also have a "type" for transactions and want to avoid 9 | # confusion btw them. 10 | type: nil, 11 | scout_name: nil 12 | 13 | def new(category, _mod, fun, args, guards, body, opts \\ []) do 14 | %__MODULE__{ 15 | function_name: fun, 16 | args: args, 17 | guards: guards, 18 | body: body, 19 | type: category, 20 | scout_name: opts[:name] || default_scout_name(fun) 21 | } 22 | end 23 | 24 | defp default_scout_name(fun) do 25 | fun 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/scout_apm/config/defaults.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Config.Defaults do 2 | def load do 3 | %{ 4 | host: "https://checkin.scoutapp.com", 5 | direct_host: "https://apm.scoutapp.com", 6 | dev_trace: false, 7 | monitor: true, 8 | ignore: [], 9 | core_agent_dir: "/tmp/scout_apm_core", 10 | core_agent_download: true, 11 | core_agent_launch: true, 12 | core_agent_version: "v1.2.6", 13 | core_agent_tcp_ip: {127, 0, 0, 1}, 14 | core_agent_tcp_port: 9000, 15 | collector_module: ScoutApm.Core.AgentManager, 16 | download_url: 17 | "https://s3-us-west-1.amazonaws.com/scout-public-downloads/apm_core_agent/release" 18 | } 19 | end 20 | 21 | def contains?(data, key) do 22 | data[key] != nil 23 | end 24 | 25 | def lookup(data, key) do 26 | data[key] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/scout_apm/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.ConfigTest do 2 | use ExUnit.Case, async: false 3 | 4 | test "find/1 with plain value" do 5 | Application.put_env(:scout_apm, :key, "abc123") 6 | 7 | key = ScoutApm.Config.find(:key) 8 | 9 | assert key == "abc123" 10 | Application.delete_env(:scout_apm, :key) 11 | end 12 | 13 | test "find/1 with application defined ENV variable" do 14 | System.put_env("APM_API_KEY", "xyz123") 15 | Application.put_env(:scout_apm, :key, {:system, "APM_API_KEY"}) 16 | 17 | key = ScoutApm.Config.find(:key) 18 | System.delete_env("APM_API_KEY") 19 | 20 | assert key == "xyz123" 21 | end 22 | 23 | test "find/1 with SCOUT_* ENV variables" do 24 | System.put_env("SCOUT_KEY", "zxc") 25 | key = ScoutApm.Config.find(:key) 26 | assert key == "zxc" 27 | System.delete_env("SCOUT_KEY") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/scout_apm/direct_analysis_store.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.DirectAnalysisStore do 2 | alias ScoutApm.Internal.WebTrace 3 | alias ScoutApm.Payload.SlowTransaction 4 | 5 | @trace_key :tracked_request 6 | 7 | def record(tracked_request) do 8 | Process.put(@trace_key, tracked_request) 9 | end 10 | 11 | def get_tracked_request do 12 | Process.get(@trace_key) 13 | end 14 | 15 | def payload do 16 | Map.merge(transaction(), %{metadata: metadata()}) 17 | end 18 | 19 | def transaction do 20 | if get_tracked_request() do 21 | get_tracked_request() |> WebTrace.from_tracked_request() |> SlowTransaction.new() 22 | else 23 | %{} 24 | end 25 | end 26 | 27 | def metadata do 28 | ScoutApm.Payload.Metadata.new(NaiveDateTime.utc_now()) 29 | end 30 | 31 | def encode(nil), do: Jason.encode!(%{}) 32 | def encode(payload), do: Jason.encode!(payload) 33 | end 34 | -------------------------------------------------------------------------------- /lib/scout_apm/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | collector_module = ScoutApm.Config.find(:collector_module) 8 | 9 | children = [ 10 | ScoutApm.PersistentHistogram, 11 | Supervisor.child_spec({ScoutApm.Watcher, ScoutApm.PersistentHistogram}, id: :histogram_watcher), 12 | collector_module 13 | ] 14 | 15 | ScoutApm.Cache.setup() 16 | 17 | # Stupidly persistent. Really high max restarts for debugging 18 | # opts = [strategy: :one_for_all, max_restarts: 10000000, max_seconds: 1, name: ScoutApm.Supervisor] 19 | opts = [strategy: :one_for_all, name: ScoutApm.Supervisor] 20 | {:ok, pid} = Supervisor.start_link(children, opts) 21 | 22 | ScoutApm.Watcher.start_link(ScoutApm.Supervisor) 23 | 24 | ScoutApm.Logger.log(:info, "ScoutAPM Started") 25 | {:ok, pid} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.4 4 | - 1.5 5 | - 1.6 6 | - 1.7 7 | - 1.8 8 | - 1.9 9 | otp_release: 10 | - 19.3 11 | - 20.3 12 | - 21.3 13 | env: 14 | - STRICT=true 15 | - STRICT=false 16 | matrix: 17 | exclude: 18 | - elixir: 1.4 19 | env: STRICT=true 20 | - elixir: 1.5 21 | env: STRICT=true 22 | - elixir: 1.6 23 | env: STRICT=true 24 | - elixir: 1.7 25 | env: STRICT=true 26 | - elixir: 1.8 27 | env: STRICT=true 28 | - elixir: 1.9 29 | env: STRICT=false 30 | - elixir: 1.4 31 | otp_release: 21.3 32 | - elixir: 1.5 33 | otp_release: 21.3 34 | - elixir: 1.8 35 | otp_release: 19.3 36 | - elixir: 1.9 37 | otp_release: 19.3 38 | script: 39 | - if [ "$STRICT" = "true" ]; then mix compile --warnings-as-errors; fi 40 | - mix test 41 | - if [ "$STRICT" = "true" ]; then mix format --dry-run --check-formatted; fi 42 | -------------------------------------------------------------------------------- /lib/scout_apm/config/env.ex: -------------------------------------------------------------------------------- 1 | # Looks up configurations in SCOUT_* namespace, in the same manner as the Ruby agent does. 2 | # For any given key ("monitor" for instance), it is uppercased, and prepended 3 | # with "SCOUT_" and then looked up in the environment. 4 | # 5 | # If you wish to define your own environment variables to use, instead of these 6 | # defaults, the Application config {:system, "MY_SCOUT_KEY_VAR"} approach allows 7 | # that 8 | defmodule ScoutApm.Config.Env do 9 | def load do 10 | :no_data 11 | end 12 | 13 | def contains?(_data, key) do 14 | System.get_env(env_name(key)) != nil 15 | end 16 | 17 | def lookup(_data, key) do 18 | System.get_env(env_name(key)) 19 | end 20 | 21 | @env_prefix "SCOUT_" 22 | defp env_name(key) when is_atom(key), do: key |> to_string |> env_name 23 | 24 | defp env_name(key) when is_binary(key), 25 | do: key |> String.upcase() |> (fn k -> @env_prefix <> k end).() 26 | end 27 | -------------------------------------------------------------------------------- /test/support/test_plug_app.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TestPlugApp do 2 | use Plug.Router 3 | 4 | plug(:match) 5 | plug(:add_private_phoenix_controller) 6 | plug(ScoutApm.Plugs.ControllerTimer) 7 | plug(:dispatch) 8 | 9 | get "/" do 10 | conn = fetch_query_params(conn) 11 | 12 | if Map.get(conn.query_params, "ignore") == "true" do 13 | ScoutApm.TrackedRequest.ignore() 14 | end 15 | 16 | put_private(conn, :phoenix_action, :index) 17 | |> send_resp(200, "") 18 | end 19 | 20 | get "/500" do 21 | put_private(conn, :phoenix_action, :"500") 22 | |> send_resp(500, "") 23 | end 24 | 25 | get "/x-forwarded-for" do 26 | put_req_header(conn, "x-forwarded-for", "1.2.3.4") 27 | |> put_private(:phoenix_action, :"x-forwarded-for") 28 | |> send_resp(200, "") 29 | end 30 | 31 | def add_private_phoenix_controller(conn, _opts) do 32 | put_private(conn, :phoenix_controller, Elixir.MyTestApp.PageController) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/scout_apm/scope_stack.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.ScopeStack do 2 | @moduledoc """ 3 | Internal to ScoutApm agent. 4 | 5 | Used as a helper to track the current scope a layer is under, as we 6 | build up a trace. 7 | 8 | This doesn't have any way to pop, since it's used in a recursive call, 9 | and coppies should just be tossed as the call stack finishes 10 | """ 11 | 12 | alias ScoutApm.Internal.Layer 13 | 14 | @max_depth 2 15 | 16 | def new() do 17 | [] 18 | end 19 | 20 | def push_scope(stack, %Layer{scopable: false}), do: stack 21 | def push_scope(stack, %Layer{} = layer), do: push_scope(stack, layer_to_scope(layer)) 22 | 23 | def push_scope(stack, %{} = scope) do 24 | if Enum.count(stack) >= @max_depth do 25 | stack 26 | else 27 | [scope | stack] 28 | end 29 | end 30 | 31 | def layer_to_scope(%Layer{} = layer) do 32 | %{type: layer.type, name: layer.name} 33 | end 34 | 35 | def current_scope([scope | _]), do: scope 36 | def current_scope([]), do: nil 37 | end 38 | -------------------------------------------------------------------------------- /priv/static/devtrace/xml_http_script.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | -------------------------------------------------------------------------------- /lib/scout_apm/payload/metadata.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Payload.Metadata do 2 | defstruct [ 3 | :app_root, 4 | :unique_id, 5 | :payload_version, 6 | :agent_version, 7 | :agent_time, 8 | :agent_pid, 9 | :platform, 10 | :platform_version, 11 | :language, 12 | :language_version 13 | ] 14 | 15 | def new(timestamp) do 16 | app_root = 17 | case File.cwd() do 18 | {:ok, path} -> 19 | path 20 | 21 | {:error, _reason} -> 22 | nil 23 | end 24 | 25 | %__MODULE__{ 26 | app_root: app_root, 27 | unique_id: ScoutApm.Utils.random_string(20), 28 | payload_version: 1, 29 | agent_version: ScoutApm.Utils.agent_version(), 30 | agent_time: timestamp |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(), 31 | agent_pid: System.get_pid() |> String.to_integer(), 32 | platform: "elixir", 33 | platform_version: System.version(), 34 | language: "elixir", 35 | language_version: System.version() 36 | } 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/scout_apm/cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.CacheTest do 2 | use ExUnit.Case 3 | 4 | ############## 5 | # Hostname # 6 | ############## 7 | 8 | test "stores hostname" do 9 | assert is_binary(ScoutApm.Cache.hostname()) 10 | end 11 | 12 | ############# 13 | # Git SHA # 14 | ############# 15 | 16 | test "determines git sha from Heroku ENV" do 17 | System.put_env("HEROKU_SLUG_COMMIT", "abcd") 18 | assert ScoutApm.Cache.determine_git_sha() == "abcd" 19 | System.delete_env("HEROKU_SLUG_COMMIT") 20 | end 21 | 22 | test "determines git sha from SCOUT_REVISION_SHA env" do 23 | System.put_env("SCOUT_REVISION_SHA", "1234") 24 | assert ScoutApm.Cache.determine_git_sha() == "1234" 25 | System.delete_env("SCOUT_REVISION_SHA") 26 | end 27 | 28 | test "determines git sha from revision_sha application setting" do 29 | Application.put_all_env(scout_apm: [revision_sha: "abc123"]) 30 | assert ScoutApm.Cache.determine_git_sha() == "abc123" 31 | Application.delete_env(:scout_apm, :revision_sha) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/ecto_telemetry.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Telemetry) || Code.ensure_loaded?(:telemetry) do 2 | defmodule ScoutApm.Instruments.EctoTelemetry do 3 | @doc """ 4 | Attaches an event handler for Ecto queries. 5 | 6 | Takes a fully namespaced Ecto.Repo module as the only argument. Example: 7 | 8 | ScoutApm.Instruments.EctoTelemetry.attach(MyApp.Repo) 9 | """ 10 | def attach(repo_module) do 11 | query_event = 12 | repo_module 13 | |> Module.split() 14 | |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom())) 15 | |> Kernel.++([:query]) 16 | 17 | :telemetry.attach( 18 | "ScoutApm Ecto Instrument Hook for " <> Macro.underscore(repo_module), 19 | query_event, 20 | &ScoutApm.Instruments.EctoTelemetry.handle_event/4, 21 | nil 22 | ) 23 | end 24 | 25 | def handle_event(query_event, value, metadata, _config) when is_list(query_event) do 26 | if :query == List.last(query_event) do 27 | ScoutApm.Instruments.EctoLogger.record(value, metadata) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/scout_apm/config/coercions.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Config.Coercions do 2 | @moduledoc """ 3 | Takes "raw" values from various config sources, and turns them into the 4 | requested format. 5 | """ 6 | 7 | @doc """ 8 | Returns {:ok, true}, {:ok, false}, or :error 9 | Attempts to "parse" a string 10 | """ 11 | @truthy ["true", "t", "1"] 12 | @falsey ["false", "f", "0"] 13 | def boolean(b) when is_boolean(b), do: {:ok, b} 14 | def boolean(s) when s in @truthy, do: {:ok, true} 15 | def boolean(s) when s in @falsey, do: {:ok, false} 16 | 17 | def boolean(s) when is_binary(s) do 18 | downcased = String.downcase(s) 19 | 20 | if downcased != s do 21 | boolean(downcased) 22 | else 23 | :error 24 | end 25 | end 26 | 27 | def boolean(_), do: :error 28 | 29 | def json(json) when is_list(json), do: {:ok, json} 30 | def json(json) when is_map(json), do: {:ok, json} 31 | 32 | def json(json) when is_binary(json) do 33 | case Jason.decode(json) do 34 | {:ok, json} -> {:ok, json} 35 | {:error, _} -> :error 36 | end 37 | end 38 | 39 | def json(_), do: :error 40 | end 41 | -------------------------------------------------------------------------------- /lib/scout_apm/context.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Context do 2 | @moduledoc """ 3 | Public API for easily adding Context to a running request. 4 | 5 | These functions must be called from the process handling the request, 6 | since the correct underlying TrackedRequest is looked up that way. 7 | """ 8 | 9 | @doc """ 10 | Returns :ok on success 11 | Returns {:error, {:arg, reason}} on failure 12 | """ 13 | def add(key, value) do 14 | case ScoutApm.Internal.Context.new(:extra, key, value) do 15 | {:error, _} = err -> 16 | err 17 | 18 | {:ok, context} -> 19 | ScoutApm.TrackedRequest.record_context(context) 20 | :ok 21 | end 22 | end 23 | 24 | @doc """ 25 | A user-specific bit of context. Gets special treatment in the UI, but 26 | otherwise follows the same rules as the `add` function. 27 | """ 28 | def add_user(key, value) do 29 | case ScoutApm.Internal.Context.new(:user, key, value) do 30 | {:error, _} = err -> 31 | err 32 | 33 | {:ok, context} -> 34 | ScoutApm.TrackedRequest.record_context(context) 35 | :ok 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/test_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TestCollector do 2 | use GenServer 3 | @behaviour ScoutApm.Collector 4 | 5 | def start_link(_) do 6 | options = [] 7 | GenServer.start_link(__MODULE__, options, name: __MODULE__) 8 | end 9 | 10 | @impl GenServer 11 | def init(_) do 12 | {:ok, %{messages: []}} 13 | end 14 | 15 | @impl ScoutApm.Collector 16 | def send(message) when is_map(message) do 17 | GenServer.cast(__MODULE__, {:send, message}) 18 | end 19 | 20 | def messages do 21 | GenServer.call(__MODULE__, :messages) 22 | end 23 | 24 | def clear_messages do 25 | GenServer.call(__MODULE__, :clear_messages) 26 | end 27 | 28 | @impl GenServer 29 | def handle_cast({:send, message}, %{messages: messages} = state) when is_map(message) do 30 | {:noreply, %{state | messages: [message | messages]}} 31 | end 32 | 33 | @impl GenServer 34 | def handle_call(:messages, _from, %{messages: messages} = state) do 35 | {:reply, Enum.reverse(messages), state} 36 | end 37 | 38 | def handle_call(:clear_messages, _from, state) do 39 | {:reply, :ok, %{state | messages: []}} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # This configuration is loaded before any dependency and is restricted 4 | # to this project. If another project depends on this project, this 5 | # file won't be loaded nor affect the parent project. For this reason, 6 | # if you want to provide default values for your application for 7 | # 3rd-party users, it should be done in your "mix.exs" file. 8 | 9 | # You can configure for your application as: 10 | # 11 | # config :scout_apm, key: :value 12 | # 13 | # And access this configuration in your application as: 14 | # 15 | # Application.get_env(:scout_apm, :key) 16 | # 17 | # Or configure a 3rd-party app: 18 | # 19 | # config :logger, level: :info 20 | # 21 | config :phoenix, :json_library, Jason 22 | 23 | # It is also possible to import configuration files, relative to this 24 | # directory. For example, you can emulate configuration per environment 25 | # by uncommenting the line below and defining dev.exs, test.exs and such. 26 | # Configuration from the imported file will override the ones defined 27 | # here (which is why it is important to import them last). 28 | # 29 | import_config "#{Mix.env()}.exs" 30 | -------------------------------------------------------------------------------- /lib/scout_apm/watcher.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Watcher do 2 | @moduledoc """ 3 | A simple module to log when a watched process fails. Only works to watch 4 | module based workers currently, not arbitrary pids. See usage in application.ex 5 | """ 6 | 7 | use GenServer 8 | 9 | @server __MODULE__ 10 | 11 | def start_link(mod) do 12 | name = 13 | mod 14 | |> Atom.to_string() 15 | |> Kernel.<>(".Watcher") 16 | |> String.to_atom() 17 | 18 | GenServer.start_link(@server, mod, name: name) 19 | end 20 | 21 | def init(mod) do 22 | Process.monitor(mod) 23 | ScoutApm.Logger.log(:info, "Setup ScoutApm.Watcher on #{inspect(mod)}") 24 | {:ok, :ok} 25 | end 26 | 27 | # If the logger itself is the one that died on us, we probably will 28 | # not log that. Also, I'm not sure of the order of events. Say that 29 | # `Store` crashes, both the supervisor and this watcher get notified, 30 | # but the supervisor will shut down and restart this process as well. 31 | def handle_info({:DOWN, _, _, {what, _node}, reason}, state) do 32 | ScoutApm.Logger.log(:info, "ScoutAPM Watcher: #{inspect(what)} Stopped: #{inspect(reason)}") 33 | {:stop, :normal, state} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/eex_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.EExEngine do 2 | @behaviour Phoenix.Template.Engine 3 | 4 | # TODO: Make this name correctly for other template locations 5 | # Currently it assumes too much about being located under `web/templates` 6 | def compile(path, name) do 7 | # web/templates/page/index.html.eex 8 | scout_name = 9 | path 10 | # [web, templates, page, index.html.eex] 11 | |> String.split("/") 12 | # [page, index.html.eex] 13 | |> Enum.drop(2) 14 | # "page/index.html.eex" 15 | |> Enum.join("/") 16 | 17 | # Since we only have a single layer of nesting currently, and 18 | # practically every template will be "under" a layout, don't let the 19 | # layout become the scope. Once we have deeper nesting, we'll want 20 | # to allow layouts as scopable layers. 21 | is_layout = String.starts_with?(scout_name, "layout") 22 | 23 | quoted_template = Phoenix.Template.EExEngine.compile(path, name) 24 | 25 | quote do 26 | require ScoutApm.Tracing 27 | 28 | ScoutApm.Tracing.timing("EEx", unquote(path), [scopable: !unquote(is_layout)], 29 | do: unquote(quoted_template) 30 | ) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/scout_apm/config/coercion_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Config.CoercionsTest do 2 | use ExUnit.Case, async: true 3 | alias ScoutApm.Config.Coercions 4 | 5 | test "boolean/1" do 6 | assert {:ok, true} = Coercions.boolean("t") 7 | assert {:ok, true} = Coercions.boolean("true") 8 | assert {:ok, true} = Coercions.boolean("1") 9 | assert {:ok, true} = Coercions.boolean("True") 10 | assert {:ok, true} = Coercions.boolean("TruE") 11 | assert {:ok, true} = Coercions.boolean("T") 12 | 13 | assert {:ok, false} = Coercions.boolean("false") 14 | assert {:ok, false} = Coercions.boolean("f") 15 | assert {:ok, false} = Coercions.boolean("0") 16 | assert {:ok, false} = Coercions.boolean("False") 17 | assert {:ok, false} = Coercions.boolean("FaLSe") 18 | assert {:ok, false} = Coercions.boolean("F") 19 | 20 | assert :error = Coercions.boolean("anything else") 21 | assert :error = Coercions.boolean(20) 22 | end 23 | 24 | test "json/1" do 25 | assert {:ok, ["list", "list"]} = Coercions.json(~s/["list", "list"]/) 26 | assert {:ok, %{"key" => "value"}} = Coercions.json(~s/{"key": "value"}/) 27 | assert :error = Coercions.json(1) 28 | assert :error = Coercions.json("notjson") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/scout_apm/payload/slow_transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Payload.SlowTransaction do 2 | @moduledoc """ 3 | The payload structure for a single SlowTransaction / Trace. 4 | """ 5 | 6 | alias ScoutApm.Internal.WebTrace 7 | alias ScoutApm.Internal.Duration 8 | 9 | defstruct [ 10 | :key, 11 | :time, 12 | :metrics, 13 | :allocation_metrics, 14 | :total_call_time, 15 | :uri, 16 | :context, 17 | :score, 18 | :mem_delta, 19 | :allocations, 20 | :seconds_since_startup, 21 | :hostname, 22 | :git_sha, 23 | :truncated_metrics 24 | ] 25 | 26 | def new(%WebTrace{} = trace) do 27 | %__MODULE__{ 28 | key: %{ 29 | bucket: trace.type, 30 | name: trace.name 31 | }, 32 | context: ScoutApm.Payload.Context.new(trace.contexts), 33 | time: trace.time, 34 | total_call_time: Duration.as(trace.total_call_time, :seconds), 35 | uri: trace.uri, 36 | metrics: trace.metrics |> Enum.map(fn m -> ScoutApm.Payload.Metric.new(m) end), 37 | score: trace.score, 38 | mem_delta: 0, 39 | allocation_metrics: %{}, 40 | allocations: 0, 41 | seconds_since_startup: 0, 42 | hostname: trace.hostname, 43 | git_sha: trace.git_sha, 44 | truncated_metrics: %{} 45 | } 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/scout_apm/payload/metric.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Payload.Metric do 2 | alias ScoutApm.Internal.Duration 3 | 4 | def new(%ScoutApm.Internal.Metric{} = metric) do 5 | %{ 6 | key: %{ 7 | bucket: metric.type, 8 | name: metric.name, 9 | desc: metric.desc, 10 | extra: make_extra(metric), 11 | scope: 12 | case metric.scope do 13 | %{:type => type, :name => name} -> 14 | %{ 15 | bucket: type, 16 | name: name 17 | } 18 | 19 | _ -> 20 | %{} 21 | end 22 | }, 23 | call_count: metric.call_count, 24 | total_call_time: Duration.as(metric.total_time, :seconds), 25 | total_exclusive_time: Duration.as(metric.exclusive_time, :seconds), 26 | min_call_time: Duration.as(metric.min_time, :seconds), 27 | max_call_time: Duration.as(metric.max_time, :seconds), 28 | # Unused, but still part of payload 29 | sum_of_squares: 0 30 | } 31 | end 32 | 33 | defp make_extra(_metric) do 34 | nil 35 | # case metric.backtrace do 36 | # nil -> nil 37 | # backtrace -> 38 | # %{backtrace: convert_backtrace(metric.backtrace)} 39 | # end 40 | end 41 | 42 | # defp convert_backtrace(backtrace) do 43 | # nil 44 | # end 45 | end 46 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/slime_engine.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(PhoenixSlime) do 2 | defmodule ScoutApm.Instruments.SlimeEngine do 3 | @behaviour Phoenix.Template.Engine 4 | 5 | # TODO: Make this name correctly for other template locations 6 | # Currently it assumes too much about being located under `web/templates` 7 | def compile(path, name) do 8 | # web/templates/page/index.html.slim(e) 9 | scout_name = 10 | path 11 | # [web, templates, page, index.html.slim(e)] 12 | |> String.split("/") 13 | # [page, index.html.slim(e)] 14 | |> Enum.drop(2) 15 | # "page/index.html.slim(e)" 16 | |> Enum.join("/") 17 | 18 | # Since we only have a single layer of nesting currently, and 19 | # practically every template will be "under" a layout, don't let the 20 | # layout become the scope. Once we have deeper nesting, we'll want 21 | # to allow layouts as scopable layers. 22 | is_layout = String.starts_with?(scout_name, "layout") 23 | 24 | quoted_template = PhoenixSlime.Engine.compile(path, name) 25 | 26 | quote do 27 | require ScoutApm.Tracing 28 | 29 | ScoutApm.Tracing.timing("Slime", unquote(path), [scopable: !unquote(is_layout)], 30 | do: unquote(quoted_template) 31 | ) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/test_tracing.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TestTracing do 2 | import ScoutApm.Tracing 3 | 4 | deftransaction add_one(integer) when is_integer(integer) do 5 | integer + 1 6 | end 7 | 8 | deftransaction add_one(number) when is_float(number) do 9 | number + 1.0 10 | end 11 | 12 | deftransaction add_one_with_error(number) do 13 | ScoutApm.TrackedRequest.mark_error() 14 | number + 1 15 | end 16 | 17 | @transaction_opts [name: "test1", type: "web"] 18 | deftransaction add_two(integer) when is_integer(integer) do 19 | integer + 2 20 | end 21 | 22 | @transaction_opts [name: "test2", type: "background"] 23 | deftransaction add_two(number) when is_float(number) do 24 | number + 2.0 25 | end 26 | 27 | deftiming add_three(integer) when is_integer(integer) do 28 | integer + 3 29 | end 30 | 31 | deftiming add_three(number) when is_float(number) do 32 | number + 3.0 33 | end 34 | 35 | @timing_opts [name: "add integers", category: "Adding"] 36 | deftiming add_four(integer) when is_integer(integer) do 37 | integer + 4 38 | end 39 | 40 | @transaction_opts [name: "add floats", type: "web"] 41 | deftransaction add_four(number) when is_float(number) do 42 | number + 4.0 43 | end 44 | 45 | deftransaction add_five(number) do 46 | if number > 2 do 47 | ScoutApm.TrackedRequest.ignore() 48 | end 49 | 50 | number + 5 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/scout_apm/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Config do 2 | @moduledoc """ 3 | Public interface to configuration settings. Reads from several configuration 4 | sources, giving each an opportunity to respond with its value before trying 5 | the next. 6 | 7 | Application.get_env, and Defaults are the the current ones, with 8 | an always-nil at the end of the chain. 9 | """ 10 | 11 | alias ScoutApm.Config.Coercions 12 | 13 | @config_modules [ 14 | {ScoutApm.Config.Env, ScoutApm.Config.Env.load()}, 15 | {ScoutApm.Config.Application, ScoutApm.Config.Application.load()}, 16 | {ScoutApm.Config.Defaults, ScoutApm.Config.Defaults.load()}, 17 | {ScoutApm.Config.Null, ScoutApm.Config.Null.load()} 18 | ] 19 | 20 | def find(key) do 21 | Enum.reduce_while(@config_modules, nil, fn {mod, data}, _acc -> 22 | if mod.contains?(data, key) do 23 | raw = mod.lookup(data, key) 24 | 25 | case coercion(key).(raw) do 26 | {:ok, c} -> 27 | {:halt, c} 28 | 29 | :error -> 30 | ScoutApm.Logger.log(:info, "Coercion of configuration #{key} failed. Ignoring") 31 | {:cont, nil} 32 | end 33 | else 34 | {:cont, nil} 35 | end 36 | end) 37 | end 38 | 39 | defp coercion(:monitor), do: &Coercions.boolean/1 40 | defp coercion(:ignore), do: &Coercions.json/1 41 | defp coercion(_), do: fn x -> {:ok, x} end 42 | end 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scout Elixir Performance Monitoring Agent 2 | 3 | `scout_apm` monitors the performance of Elixir applications in production and provides an in-browser profiler during development. Metrics are 4 | reported to [Scout](https://scoutapp.com), a hosted application monitoring service. 5 | 6 | ![screenshot](https://s3-us-west-1.amazonaws.com/scout-blog/elixir_screenshot.png) 7 | 8 | ## Monitoring Usage 9 | 10 | 1. Signup for a [free Scout account](https://scoutapp.com/info/pricing). 11 | 2. Follow our install instructions within the UI. 12 | 13 | [See our docs](http://docs.scoutapm.com/#elixir-agent) for detailed information. 14 | 15 | ## DevTrace (Development Profiler) Usage 16 | 17 | DevTrace, Scout's in-browser development profiler, may be used without signup. 18 | 19 | ![devtrace](http://docs.scoutapm.com/images/devtrace.png) 20 | 21 | To use: 22 | 23 | 1. [Follow the same installation steps as monitoring](http://docs.scoutapm.com/#elixir-install), but skip downloading the config file. 24 | 2. In your `config/dev.exs` file, add: 25 | ```elixir 26 | # config/dev.exs 27 | config :scout_apm, 28 | dev_trace: true 29 | ``` 30 | 3. Restart your app. 31 | 4. Refresh your browser window and look for the speed badge. 32 | 33 | ## Instrumentation 34 | 35 | See [our docs](http://docs.scoutapm.com/#elixir-instrumented-libaries) for information on libraries we auto-instrument (like Phoenix controller-actions) and guides for instrumenting Phoenix channels, Task, HTTPoison, GenServer, and more. 36 | -------------------------------------------------------------------------------- /lib/scout_apm/persistent_histogram.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.PersistentHistogram do 2 | use Agent 3 | @name __MODULE__ 4 | 5 | def start_link(_) do 6 | Agent.start_link(fn -> %{} end, name: @name) 7 | end 8 | 9 | # This is synchronous. 10 | def record_timing(key, timing) do 11 | Agent.update( 12 | @name, 13 | fn state -> 14 | Map.update( 15 | state, 16 | key, 17 | ApproximateHistogram.new(), 18 | fn histo -> ApproximateHistogram.add(histo, timing) end 19 | ) 20 | end 21 | ) 22 | end 23 | 24 | def keys do 25 | Agent.get(@name, fn state -> Map.keys(state) end) 26 | end 27 | 28 | # Returns {:ok, percentile} or :error 29 | def percentile(key, timing) do 30 | Agent.get( 31 | @name, 32 | fn state -> 33 | case Map.fetch(state, key) do 34 | {:ok, histo} -> 35 | p = ApproximateHistogram.percentile(histo, timing) 36 | {:ok, p} 37 | 38 | _ -> 39 | :error 40 | end 41 | end 42 | ) 43 | end 44 | 45 | # Returns {:ok, percentile} or :error 46 | def percentile_for_value(key, value) do 47 | Agent.get( 48 | @name, 49 | fn state -> 50 | case Map.fetch(state, key) do 51 | {:ok, histo} -> 52 | {:ok, ApproximateHistogram.percentile_for_value(histo, value)} 53 | 54 | _ -> 55 | :error 56 | end 57 | end 58 | ) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/scout_apm/internal/context.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.Context do 2 | @moduledoc """ 3 | Internal representation of a Context. As a user of ScoutApm, you 4 | likely will not need this module 5 | """ 6 | 7 | defstruct [:type, :key, :value] 8 | 9 | @valid_types [:user, :extra] 10 | @type context_types :: :user | :extra 11 | 12 | @type t :: %__MODULE__{ 13 | type: context_types, 14 | key: String.t(), 15 | value: number | boolean | String.t() 16 | } 17 | 18 | def new(type, key, value) do 19 | case {valid_type?(type), valid_key?(key), valid_value?(value)} do 20 | {false, _, _} -> 21 | {:error, {:type, :invalid}} 22 | 23 | {_, false, _} -> 24 | {:error, {:key, :invalid_type}} 25 | 26 | {_, _, false} -> 27 | {:error, {:value, :invalid_type}} 28 | 29 | {true, true, true} -> 30 | {:ok, %__MODULE__{type: type, key: key, value: value}} 31 | end 32 | end 33 | 34 | defp valid_type?(t) when t in @valid_types, do: true 35 | defp valid_type?(_), do: false 36 | 37 | defp valid_key?(key) when is_binary(key), do: String.printable?(key) 38 | defp valid_key?(key) when is_atom(key), do: key |> to_string |> String.printable?() 39 | defp valid_key?(_), do: false 40 | 41 | defp valid_value?(val) when is_binary(val), do: String.printable?(val) 42 | defp valid_value?(val) when is_boolean(val), do: true 43 | defp valid_value?(val) when is_number(val), do: true 44 | defp valid_value?(_), do: false 45 | end 46 | -------------------------------------------------------------------------------- /lib/mix/tasks/scout.test_config.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scout.TestConfig do 2 | use Mix.Task 3 | 4 | @moduledoc """ 5 | Checks application configuration and core agent communication 6 | """ 7 | 8 | def run(args) do 9 | unless "--no-compile" in args do 10 | Mix.Task.run("compile", args) 11 | end 12 | 13 | Application.ensure_all_started(:scout_apm) 14 | 15 | check_config() 16 | check_agent() 17 | end 18 | 19 | defp check_config do 20 | name = ScoutApm.Config.find(:name) 21 | key = ScoutApm.Config.find(:key) 22 | monitor = ScoutApm.Config.find(:monitor) 23 | 24 | Mix.shell().info("Configuration:") 25 | Mix.shell().info("Name: #{inspect(name)}") 26 | Mix.shell().info("Key: #{inspect(key)}") 27 | Mix.shell().info("Monitor: #{inspect(monitor)}") 28 | 29 | if not is_binary(name) || not is_binary(key) do 30 | Mix.raise("Scout :name and :key configuration is required to profile") 31 | end 32 | 33 | if not monitor do 34 | Mix.raise("Scout :monitor configuration must be true to profile") 35 | end 36 | end 37 | 38 | defp check_agent do 39 | message = 40 | ScoutApm.Command.ApplicationEvent.app_metadata() 41 | |> ScoutApm.Command.message() 42 | 43 | case GenServer.call(ScoutApm.Core.AgentManager, {:send, message}) do 44 | %{socket: socket} when not is_nil(socket) -> 45 | Mix.shell().info("Successfully connected to Scout Agent") 46 | 47 | _ -> 48 | Mix.raise("Could not connect to Scout Agent") 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/scout_apm/internal/duration.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.Duration do 2 | @type t :: %__MODULE__{value: number()} 3 | @type unit :: :microseconds | :milliseconds | :seconds 4 | 5 | defstruct [ 6 | :value 7 | ] 8 | 9 | @spec zero() :: t 10 | def zero(), do: %__MODULE__{value: 0} 11 | 12 | @spec new(number(), unit) :: t 13 | def new(value, unit) do 14 | %__MODULE__{value: normalize_value(value, unit)} 15 | end 16 | 17 | @spec as(t, unit) :: number() 18 | def as(%__MODULE__{value: value}, :microseconds), do: value 19 | def as(%__MODULE__{value: value}, :milliseconds), do: value / 1_000 20 | def as(%__MODULE__{value: value}, :seconds), do: value / 1_000_000 21 | 22 | @spec add(t, t) :: t 23 | def add(%__MODULE__{value: v1}, %__MODULE__{value: v2}) do 24 | %__MODULE__{value: v1 + v2} 25 | end 26 | 27 | @spec subtract(t, t) :: t 28 | def subtract(%__MODULE__{value: v1}, %__MODULE__{value: v2}) do 29 | %__MODULE__{value: v1 - v2} 30 | end 31 | 32 | @spec min(t, t) :: t 33 | def min(%__MODULE__{value: v1}, %__MODULE__{value: v2}) do 34 | cond do 35 | v1 < v2 -> %__MODULE__{value: v1} 36 | v2 < v1 -> %__MODULE__{value: v2} 37 | v1 == v2 -> %__MODULE__{value: v1} 38 | end 39 | end 40 | 41 | @spec max(t, t) :: t 42 | def max(%__MODULE__{value: v1}, %__MODULE__{value: v2}) do 43 | cond do 44 | v1 > v2 -> %__MODULE__{value: v1} 45 | v2 > v1 -> %__MODULE__{value: v2} 46 | v1 == v2 -> %__MODULE__{value: v1} 47 | end 48 | end 49 | 50 | defp normalize_value(value, :microseconds), do: value 51 | defp normalize_value(value, :milliseconds), do: value * 1000 52 | defp normalize_value(value, :seconds), do: value * 1_000_000 53 | end 54 | -------------------------------------------------------------------------------- /lib/scout_apm/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Logger do 2 | @moduledoc """ 3 | Logger for all ScoutApm modules. 4 | 5 | Defaults to pass-through to the built-in Logger, but checks first if 6 | the agent is set to monitor: false, or set to a higher log level. 7 | 8 | Due to using Logger.log/2, ScoutApm.Logger calls will not be eliminated 9 | at compile-time. 10 | """ 11 | 12 | require Logger 13 | 14 | @default_level :info 15 | 16 | @valid_levels [:debug, :info, :warn, :error] 17 | 18 | # If you request to log a message at the left level, check if the 19 | # logger's current level is one of the right before letting it through 20 | # 21 | # For instance, if you ScoutApm.Logger.log(:warn, "foo"), it should be 22 | # printed if you're at warn, info, or debug levels 23 | # 24 | # Msg Lvl Logger Lvl 25 | # | | 26 | # | | 27 | # v v 28 | @debug_levels [:debug] 29 | @info_levels [:debug, :info] 30 | @warn_levels [:debug, :info, :warn] 31 | @error_levels [:debug, :info, :warn, :error] 32 | 33 | @log_levels %{ 34 | debug: @debug_levels, 35 | info: @info_levels, 36 | warn: @warn_levels, 37 | error: @error_levels 38 | } 39 | 40 | def log(level, chardata_or_fun, metadata \\ []) when level in @valid_levels do 41 | log_level = ScoutApm.Config.find(:log_level) || @default_level 42 | 43 | with {:ok, levels} <- logging_enabled() && Map.fetch(@log_levels, level), 44 | true <- log_level in levels do 45 | Logger.log(level, chardata_or_fun, metadata) 46 | else 47 | _ -> :ok 48 | end 49 | end 50 | 51 | defp logging_enabled do 52 | enabled = ScoutApm.Config.find(:monitor) 53 | key = ScoutApm.Config.find(:key) 54 | 55 | enabled && key 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/scout_apm/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Cache do 2 | @moduledoc false 3 | @table_name :scout_cache 4 | 5 | def setup do 6 | :ets.new(@table_name, [:named_table, :set, :protected, read_concurrency: true]) 7 | 8 | :ets.insert(@table_name, {:hostname, determine_hostname()}) 9 | :ets.insert(@table_name, {:git_sha, determine_git_sha()}) 10 | end 11 | 12 | ###################################### 13 | # Public Functions to Lookup Values # 14 | ###################################### 15 | 16 | def hostname do 17 | case :ets.lookup(@table_name, :hostname) do 18 | [{:hostname, hostname}] -> hostname 19 | _ -> nil 20 | end 21 | end 22 | 23 | def git_sha do 24 | case :ets.lookup(@table_name, :git_sha) do 25 | [{:git_sha, git_sha}] when is_binary(git_sha) -> git_sha 26 | _ -> "" 27 | end 28 | end 29 | 30 | ######################## 31 | # Hostname Detection # 32 | ######################## 33 | 34 | defp determine_hostname do 35 | case heroku_hostname() do 36 | hostname when is_binary(hostname) -> hostname 37 | _ -> inet_hostname() 38 | end 39 | end 40 | 41 | defp heroku_hostname do 42 | System.get_env("DYNO") 43 | end 44 | 45 | defp inet_hostname do 46 | {:ok, hostname} = :inet.gethostname() 47 | to_string(hostname) 48 | end 49 | 50 | ####################### 51 | # Git SHA Detection # 52 | ####################### 53 | 54 | # Lookup via explicitly configured values, then if not, fall back to a heroku 55 | # setting, then ... nil 56 | def determine_git_sha do 57 | case configured_sha() do 58 | sha when is_binary(sha) -> sha 59 | _ -> heroku_sha() 60 | end 61 | end 62 | 63 | defp heroku_sha do 64 | System.get_env("HEROKU_SLUG_COMMIT") 65 | end 66 | 67 | # Looks in all normal configuration locations (Application, {:system, ENV}, ENV) 68 | defp configured_sha do 69 | ScoutApm.Config.find(:revision_sha) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/scout_apm/core/manifest.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Core.Manifest do 2 | defstruct [:version, :bin_version, :bin_name, :sha256, :valid, :directory] 3 | alias __MODULE__ 4 | 5 | @type t :: %__MODULE__{ 6 | version: String.t() | nil, 7 | bin_version: String.t() | nil, 8 | bin_name: String.t() | nil, 9 | sha256: String.t() | nil, 10 | valid: boolean, 11 | directory: String.t() 12 | } 13 | 14 | @spec build_from_directory(String.t(), String.t()) :: t() 15 | def build_from_directory(directory, file \\ "manifest.json") do 16 | manifest_path = Path.join([directory, file]) 17 | 18 | with {:ok, binary} <- File.read(manifest_path), 19 | {:ok, json} <- Jason.decode(binary), 20 | {:ok, version} <- Map.fetch(json, "version"), 21 | {:ok, bin_version} <- Map.fetch(json, "core_agent_version"), 22 | {:ok, bin_name} <- Map.fetch(json, "core_agent_binary"), 23 | {:ok, sha} <- Map.fetch(json, "core_agent_binary_sha256") do 24 | %__MODULE__{ 25 | directory: directory, 26 | version: version, 27 | bin_version: bin_version, 28 | bin_name: bin_name, 29 | sha256: sha, 30 | valid: true 31 | } 32 | else 33 | _ -> 34 | %__MODULE__{ 35 | directory: directory, 36 | valid: false 37 | } 38 | end 39 | end 40 | 41 | @spec bin_path(t()) :: String.t() 42 | def bin_path(%Manifest{directory: dir, bin_name: bin}), do: Path.join([dir, bin]) 43 | 44 | @spec sha256_valid?(t()) :: boolean | :error 45 | def sha256_valid?(%Manifest{valid: true} = manifest) do 46 | bin_path = Manifest.bin_path(manifest) 47 | 48 | with {:ok, content} <- File.read(bin_path), 49 | hash <- :crypto.hash(:sha256, content), 50 | encoded <- Base.encode16(hash, case: :lower) do 51 | manifest.sha256 == encoded 52 | else 53 | _ -> 54 | ScoutApm.Logger.log(:debug, "Core Agent verification failed due to SHA mismatch") 55 | :error 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :scout_apm, 7 | version: "1.0.7", 8 | elixir: "~> 1.14", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | deps: deps(), 13 | description: description(), 14 | package: package() 15 | ] 16 | end 17 | 18 | def application do 19 | # Specify extra applications you'll use from Erlang/Elixir 20 | [ 21 | extra_applications: [ 22 | :logger 23 | ], 24 | mod: {ScoutApm.Application, []} 25 | ] 26 | end 27 | 28 | defp deps do 29 | [ 30 | {:plug, "~>1.0"}, 31 | {:jason, "~> 1.0"}, 32 | 33 | # We only use `request/5` from hackney, which hasn't changed in the 1.0 line. 34 | {:hackney, "~> 1.0"}, 35 | {:approximate_histogram, "~>0.1.1"}, 36 | {:telemetry, "~> 1.0", optional: true}, 37 | 38 | ######################### 39 | # Dev & Testing Deps 40 | 41 | {:ex_doc, ">= 0.0.0", only: [:dev]}, 42 | {:credo, "~> 0.5", only: [:dev, :test]}, 43 | {:dialyxir, "~> 0.5", only: [:dev], runtime: false}, 44 | 45 | # TODO: Should this be in the dev-only dependencies? It is needed for dialyzer to complete correctly. 46 | {:phoenix, "~> 1.0", only: [:dev, :test]}, 47 | {:phoenix_slime, "~> 0.9.0", only: [:dev, :test]} 48 | ] 49 | end 50 | 51 | defp description() do 52 | """ 53 | ScoutAPM agent for Phoenix & Elixir projects. For more information, visit https://scoutapm.com/elixir. 54 | """ 55 | end 56 | 57 | defp package do 58 | # These are the default files included in the package 59 | [ 60 | name: :scout_apm, 61 | files: ["lib", "priv", "mix.exs", "README*", "LICENSE*"], 62 | maintainers: ["Scout Team"], 63 | licenses: ["Scout Software Agent License"], 64 | links: %{ 65 | "GitHub" => "https://github.com/scoutapp/scout_apm_elixir", 66 | "Docs" => "http://docs.scoutapm.com/#elixir-agent" 67 | } 68 | ] 69 | end 70 | 71 | defp elixirc_paths(:test), do: ["lib", "test/support"] 72 | defp elixirc_paths(_), do: ["lib"] 73 | end 74 | -------------------------------------------------------------------------------- /test/scout_apm/instruments/ecto_logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.EctoLoggerTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | ScoutApm.TestCollector.clear_messages() 6 | :ok 7 | end 8 | 9 | describe "record/2" do 10 | test "successfully records query" do 11 | value = %{ 12 | decode_time: 16000, 13 | query_time: 1_192_999, 14 | queue_time: 36000 15 | } 16 | 17 | metadata = %{ 18 | source: "users", 19 | query: "SELECT u0.\"id\", u0.\"name\", u0.\"age\" FROM \"users\" AS u0" 20 | } 21 | 22 | ScoutApm.TrackedRequest.start_layer("Controller", "test") 23 | ScoutApm.Instruments.EctoLogger.record(value, metadata) 24 | ScoutApm.TrackedRequest.stop_layer() 25 | 26 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 27 | 28 | assert Enum.any?(commands, fn command -> 29 | span = Map.get(command, :StartSpan) 30 | 31 | span && 32 | Map.get(span, :operation) == 33 | "SQL/Query" 34 | end) 35 | 36 | assert Enum.any?(commands, fn command -> 37 | tag = Map.get(command, :TagSpan) 38 | 39 | tag && 40 | Map.get(tag, :tag) == "db.statement" 41 | end) 42 | end 43 | end 44 | 45 | describe "log/1" do 46 | test "successfully records query" do 47 | entry = %{ 48 | decode_time: 16000, 49 | query_time: 1_192_999, 50 | queue_time: 36000, 51 | result: {:ok, %{__struct__: Postgrex.Result, command: :select}}, 52 | source: "users", 53 | query: "SELECT u0.\"id\", u0.\"name\", u0.\"age\" FROM \"users\" AS u0" 54 | } 55 | 56 | ScoutApm.TrackedRequest.start_layer("Controller", "test") 57 | ScoutApm.Instruments.EctoLogger.log(entry) 58 | ScoutApm.TrackedRequest.stop_layer() 59 | 60 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 61 | 62 | assert Enum.any?(commands, fn command -> 63 | span = Map.get(command, :StartSpan) 64 | 65 | span && 66 | Map.get(span, :operation) == 67 | "SQL/Query" 68 | end) 69 | 70 | assert Enum.any?(commands, fn command -> 71 | tag = Map.get(command, :TagSpan) 72 | 73 | tag && 74 | Map.get(tag, :tag) == "db.statement" 75 | end) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/scout_apm/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.LoggerTest do 2 | use ExUnit.Case, async: false 3 | import ExUnit.CaptureLog 4 | 5 | test "always logs errors" do 6 | Application.put_all_env(scout_apm: [monitor: true, key: "abc123"]) 7 | 8 | Application.put_env(:scout_apm, :log_level, :debug) 9 | 10 | assert capture_log(fn -> 11 | ScoutApm.Logger.log(:error, "Debug Log") 12 | end) =~ "Debug Log" 13 | 14 | Application.put_env(:scout_apm, :log_level, :info) 15 | 16 | assert capture_log(fn -> 17 | ScoutApm.Logger.log(:error, "Info Log") 18 | end) =~ "Info Log" 19 | 20 | Application.put_env(:scout_apm, :log_level, :warn) 21 | 22 | assert capture_log(fn -> 23 | ScoutApm.Logger.log(:error, "Warn Log") 24 | end) =~ "Warn Log" 25 | 26 | Application.put_env(:scout_apm, :log_level, :error) 27 | 28 | assert capture_log(fn -> 29 | ScoutApm.Logger.log(:error, "Error Log") 30 | end) =~ "Error Log" 31 | 32 | Application.delete_env(:scout_apm, :monitor) 33 | Application.delete_env(:scout_apm, :log_level) 34 | Application.delete_env(:scout_apm, :key) 35 | end 36 | 37 | test "only logs debug in debug level" do 38 | Application.put_all_env(scout_apm: [monitor: true, key: "abc123"]) 39 | 40 | Application.put_env(:scout_apm, :log_level, :debug) 41 | 42 | assert capture_log(fn -> 43 | ScoutApm.Logger.log(:error, "Debug Log") 44 | end) =~ "Debug Log" 45 | 46 | Application.put_env(:scout_apm, :log_level, :info) 47 | 48 | assert capture_log(fn -> 49 | ScoutApm.Logger.log(:debug, "Info Log") 50 | end) == "" 51 | 52 | Application.put_env(:scout_apm, :log_level, :warn) 53 | 54 | assert capture_log(fn -> 55 | ScoutApm.Logger.log(:debug, "Warn Log") 56 | end) == "" 57 | 58 | Application.put_env(:scout_apm, :log_level, :error) 59 | 60 | assert capture_log(fn -> 61 | ScoutApm.Logger.log(:debug, "Error Log") 62 | end) == "" 63 | 64 | Application.delete_env(:scout_apm, :monitor) 65 | Application.delete_env(:scout_apm, :log_level) 66 | Application.delete_env(:scout_apm, :key) 67 | end 68 | 69 | test "never logs if key is not configured" do 70 | Application.put_all_env(scout_apm: [monitor: true, key: nil, log_level: :debug]) 71 | 72 | assert capture_log(fn -> 73 | ScoutApm.Logger.log(:error, "Log") 74 | end) == "" 75 | 76 | Application.delete_env(:scout_apm, :monitor) 77 | Application.delete_env(:scout_apm, :log_level) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/scout_apm/instruments/ecto_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Instruments.EctoLogger do 2 | # value = %{ 3 | # decode_time: 5386000, 4 | # query_time: 9435000, 5 | # queue_time: 4549000, 6 | # total_time: 19370000 7 | # } 8 | # metadata = %{ 9 | # params: [1], 10 | # query: "SELECT p0.\"id\", p0.\"body\", p0.\"title\", p0.\"inserted_at\", p0.\"updated_at\" FROM \"posts\" AS p0 WHERE (p0.\"id\" = $1)", 11 | # repo: MyApp.Repo, 12 | # result: :ok, 13 | # source: "posts", 14 | # type: :ecto_sql_query 15 | # } 16 | 17 | def log(entry) do 18 | case query_time_log_entry(entry) do 19 | {:ok, duration} -> 20 | ScoutApm.TrackedRequest.track_layer( 21 | "Ecto", 22 | query_name_log_entry(entry), 23 | duration, 24 | desc: Map.get(entry, :query) 25 | ) 26 | 27 | {:error, _} -> 28 | nil 29 | end 30 | 31 | entry 32 | end 33 | 34 | def record(value, metadata) do 35 | case query_time(value, metadata) do 36 | {:ok, duration} -> 37 | ScoutApm.TrackedRequest.track_layer( 38 | "Ecto", 39 | query_name(value, metadata), 40 | duration, 41 | desc: Map.get(metadata, :query) 42 | ) 43 | 44 | {:error, _} -> 45 | nil 46 | end 47 | end 48 | 49 | def query_name(_value, metadata) do 50 | case Map.get(metadata, :source) do 51 | nil -> "SQL" 52 | table_name -> "SQL##{table_name}" 53 | end 54 | end 55 | 56 | def query_name_log_entry(entry) do 57 | with {:ok, {:ok, result}} <- Map.fetch(entry, :result), 58 | command <- Map.get(result, :command, "SQL"), 59 | table <- Map.get(entry, :source) do 60 | command = 61 | List.wrap(command) 62 | |> Enum.map(&to_string(&1)) 63 | |> Enum.join(",") 64 | 65 | if table do 66 | "#{command}##{table}" 67 | else 68 | "#{command}" 69 | end 70 | else 71 | _ -> 72 | "SQL" 73 | end 74 | end 75 | 76 | def query_time(%{query_time: query_time}, _telemetry_metadata) when is_integer(query_time) do 77 | microtime = System.convert_time_unit(query_time, :native, :microsecond) 78 | {:ok, ScoutApm.Internal.Duration.new(microtime, :microseconds)} 79 | end 80 | 81 | def query_time(_telemetry_value, %{query_time: query_time}) when is_integer(query_time) do 82 | microtime = System.convert_time_unit(query_time, :native, :microsecond) 83 | {:ok, ScoutApm.Internal.Duration.new(microtime, :microseconds)} 84 | end 85 | 86 | def query_time(_telemetry_value, _telemetry_metadata) do 87 | {:error, :non_integer_query_time} 88 | end 89 | 90 | def query_time_log_entry(%{query_time: query_time}) when is_integer(query_time) do 91 | microtime = System.convert_time_unit(query_time, :native, :microsecond) 92 | {:ok, ScoutApm.Internal.Duration.new(microtime, :microseconds)} 93 | end 94 | 95 | def query_time_log_entry(_entry) do 96 | {:error, :non_integer_query_time} 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/scout_apm/core.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Core do 2 | @spec socket_path :: String.t() 3 | def socket_path do 4 | socket_path = ScoutApm.Config.find(:core_agent_socket_path) 5 | 6 | if is_nil(socket_path) do 7 | dir = ScoutApm.Config.find(:core_agent_dir) 8 | 9 | Path.join([dir, "scout-agent.sock"]) 10 | else 11 | socket_path 12 | end 13 | end 14 | 15 | @spec download_url :: String.t() 16 | def download_url do 17 | url = ScoutApm.Config.find(:download_url) 18 | "#{url}/#{agent_full_name()}.tgz" 19 | end 20 | 21 | @spec agent_full_name :: String.t() 22 | def agent_full_name do 23 | full_name = ScoutApm.Config.find(:core_agent_full_name) 24 | 25 | if is_nil(full_name) do 26 | version = ScoutApm.Config.find(:core_agent_version) 27 | 28 | platform_triple = 29 | case ScoutApm.Config.find(:core_agent_triple) do 30 | triple when is_binary(triple) -> triple 31 | nil -> platform_triple() 32 | end 33 | 34 | "scout_apm_core-#{version}-#{platform_triple}" 35 | else 36 | full_name 37 | end 38 | end 39 | 40 | @spec platform_triple :: String.t() 41 | def platform_triple do 42 | "#{architecture()}-#{platform()}" 43 | end 44 | 45 | @spec platform :: String.t() 46 | def platform do 47 | case :os.type() do 48 | {:unix, :darwin} -> 49 | "apple-darwin" 50 | 51 | {:unix, _} -> 52 | libc = libc() 53 | "unknown-linux-#{libc}" 54 | 55 | _ -> 56 | "unknown" 57 | end 58 | end 59 | 60 | @spec architecture :: String.t() 61 | def architecture do 62 | case uname_architecture() do 63 | "x86_64" -> "x86_64" 64 | "i686" -> "i686" 65 | _ -> "unknown" 66 | end 67 | end 68 | 69 | @spec uname_architecture :: String.t() 70 | def uname_architecture do 71 | try do 72 | case System.cmd("uname", ["-m"]) do 73 | {arch, 0} -> String.trim(arch) 74 | _ -> "unknown" 75 | end 76 | rescue 77 | ErlangError -> "unknown" 78 | end 79 | end 80 | 81 | @spec libc :: String.t() 82 | def libc do 83 | case File.read("/etc/alpine-release") do 84 | {:ok, _} -> "musl" 85 | {:error, _} -> detect_libc_from_ldd() 86 | end 87 | end 88 | 89 | @spec detect_libc_from_ldd :: String.t() 90 | def detect_libc_from_ldd do 91 | try do 92 | {ldd_version, 0} = System.cmd("ldd", ["--version"]) 93 | 94 | if String.contains?(ldd_version, "musl") do 95 | "musl" 96 | else 97 | "gnu" 98 | end 99 | rescue 100 | ErlangError -> "gnu" 101 | end 102 | end 103 | 104 | @spec verify(String.t()) :: {:ok, ScoutApm.Core.Manifest.t()} | {:error, :invalid} 105 | def verify(dir) do 106 | manifest = ScoutApm.Core.Manifest.build_from_directory(dir) 107 | 108 | if manifest.valid && ScoutApm.Core.Manifest.sha256_valid?(manifest) do 109 | {:ok, manifest} 110 | else 111 | {:error, :invalid} 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /test/scout_apm/metric_set_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.MetricSetTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ScoutApm.MetricSet 5 | alias ScoutApm.Internal.Metric 6 | 7 | describe "new/0 and new/1" do 8 | test "creates a MetricSet with default options" do 9 | set = MetricSet.new() 10 | assert ScoutApm.MetricSet == set.__struct__ 11 | 12 | assert %{ 13 | collapse_all: false, 14 | compare_desc: false, 15 | max_types: 100 16 | } == set.options 17 | end 18 | 19 | test "accepts overriding options" do 20 | set = MetricSet.new(%{max_types: 5}) 21 | assert %{collapse_all: false, compare_desc: false, max_types: 5} == set.options 22 | 23 | set2 = MetricSet.new(%{collapse_all: true, compare_desc: true}) 24 | assert %{collapse_all: true, compare_desc: true, max_types: 100} == set2.options 25 | end 26 | end 27 | 28 | describe "absorb" do 29 | test "adds to metrics" do 30 | set = 31 | MetricSet.new() 32 | |> MetricSet.absorb(make_metric("Ecto", "select")) 33 | 34 | assert 1 == Enum.count(MetricSet.to_list(set)) 35 | end 36 | 37 | test "merges if the metric already exists" do 38 | set = 39 | MetricSet.new() 40 | |> MetricSet.absorb(make_metric("Ecto", "select")) 41 | |> MetricSet.absorb(make_metric("Ecto", "select")) 42 | |> MetricSet.absorb(make_metric("Ecto", "select")) 43 | 44 | assert 1 == Enum.count(MetricSet.to_list(set)) 45 | end 46 | 47 | test "skips metrics when over max_types" do 48 | set = 49 | MetricSet.new(%{max_types: 3}) 50 | |> MetricSet.absorb(make_metric("A", "select")) 51 | |> MetricSet.absorb(make_metric("B", "select")) 52 | |> MetricSet.absorb(make_metric("C", "select")) 53 | # skipped 54 | |> MetricSet.absorb(make_metric("D", "select")) 55 | # skipped 56 | |> MetricSet.absorb(make_metric("E", "select")) 57 | 58 | assert 3 == Enum.count(MetricSet.to_list(set)) 59 | end 60 | end 61 | 62 | describe "absorb_all" do 63 | test "adds to metrics" do 64 | set = 65 | MetricSet.new() 66 | |> MetricSet.absorb_all([ 67 | make_metric("Ecto", "select"), 68 | make_metric("Controller", "foo/bar"), 69 | make_metric("EEx", "pages/index.html") 70 | ]) 71 | 72 | assert 3 == Enum.count(MetricSet.to_list(set)) 73 | end 74 | end 75 | 76 | describe "merge" do 77 | test "merges the two sets of metrics" do 78 | set1 = 79 | MetricSet.new() 80 | |> MetricSet.absorb_all([ 81 | make_metric("Ecto", "select"), 82 | make_metric("Controller", "foo/bar"), 83 | make_metric("EEx", "pages/index.html") 84 | ]) 85 | 86 | set2 = 87 | MetricSet.new() 88 | |> MetricSet.absorb_all([ 89 | make_metric("Ecto", "select"), 90 | make_metric("Ecto", "delete"), 91 | make_metric("EEx", "pages/layout.html") 92 | ]) 93 | 94 | merged = MetricSet.merge(set1, set2) 95 | 96 | assert 5 == Enum.count(MetricSet.to_list(merged)) 97 | end 98 | end 99 | 100 | defp make_metric(type, name, timing \\ 7.5) do 101 | Metric.from_sampler_value(type, name, timing) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/scout_apm/devtrace/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.DevTrace.Plug do 2 | import Plug.Conn 3 | 4 | # This monkey-patches XMLHttpRequest. It could possibly be part of the main scout_instant.js too. This is placed in the HTML HEAD so it loads as soon as possible. 5 | xml_http_script_path = 6 | Application.app_dir(:scout_apm, "priv/static/devtrace/xml_http_script.html") 7 | 8 | @xml_http_script File.read!(xml_http_script_path) 9 | 10 | def init(default), do: default 11 | 12 | def call(conn, _) do 13 | if ScoutApm.DevTrace.enabled?() do 14 | before_send_inject_devtrace(conn) 15 | else 16 | conn 17 | end 18 | end 19 | 20 | # Phoenix.LiveReloader is used as a base for much of the injection logic. 21 | defp before_send_inject_devtrace(conn) do 22 | register_before_send(conn, fn conn -> 23 | resp_body = to_string(conn.resp_body) 24 | 25 | cond do 26 | async_request?(conn) -> 27 | add_async_header(conn) 28 | 29 | inject?(conn, resp_body) -> 30 | inject_js(conn, resp_body) 31 | 32 | true -> 33 | conn 34 | end 35 | end) 36 | end 37 | 38 | defp add_async_header(conn) do 39 | conn 40 | |> put_resp_header("X-scoutapminstant", payload()) 41 | end 42 | 43 | defp inject_js(conn, resp_body) do 44 | # HTML HEAD 45 | [page | rest] = String.split(resp_body, "") 46 | body = page <> head_tags() <> Enum.join(["" | rest], "") 47 | # HTML BODY 48 | [page | rest] = String.split(body, "") 49 | body = page <> body_tags() <> Enum.join(["" | rest], "") 50 | 51 | put_in(conn.resp_body, body) 52 | end 53 | 54 | defp apm_host do 55 | ScoutApm.Config.find(:direct_host) 56 | end 57 | 58 | defp cachebust_time do 59 | :os.system_time(:seconds) 60 | end 61 | 62 | defp head_tags do 63 | """ 64 | 65 | #{@xml_http_script} 66 | """ 67 | end 68 | 69 | defp body_tags do 70 | """ 71 | 72 | 75 | """ 76 | end 77 | 78 | defp payload do 79 | ScoutApm.DirectAnalysisStore.payload() |> ScoutApm.DirectAnalysisStore.encode() 80 | end 81 | 82 | defp async_request?(conn) do 83 | ajax_request?(conn) || json_response?(conn) 84 | end 85 | 86 | defp ajax_request?(conn) do 87 | conn 88 | |> get_req_header("x-requested-with") 89 | |> xml_http? 90 | end 91 | 92 | defp json_response?(conn) do 93 | conn 94 | |> get_resp_header("content-type") 95 | |> json_content_type? 96 | end 97 | 98 | # Direct from Phoenix.LiveReloader.inject?/2 99 | defp inject?(conn, resp_body) do 100 | conn 101 | |> get_resp_header("content-type") 102 | |> html_content_type? 103 | |> Kernel.&&(String.contains?(resp_body, " 1, the times should be the sum. 33 | :total_time, 34 | :exclusive_time, 35 | :min_time, 36 | :max_time, 37 | :backtrace 38 | ] 39 | 40 | ################## 41 | # Construction # 42 | ################## 43 | 44 | @spec from_layer_as_summary(Layer.t()) :: t 45 | def from_layer_as_summary(%Layer{} = layer) do 46 | total_time = Layer.total_time(layer) 47 | 48 | %__MODULE__{ 49 | type: layer.type, 50 | # The magic string, expected by APM server. 51 | name: "all", 52 | desc: nil, 53 | scope: nil, 54 | call_count: 1, 55 | total_time: total_time, 56 | exclusive_time: Layer.total_exclusive_time(layer), 57 | min_time: total_time, 58 | max_time: total_time, 59 | backtrace: nil 60 | } 61 | end 62 | 63 | # Layers don't know their own scope, so you need to pass it in explicitly. 64 | @spec from_layer(Layer.t(), nil | map()) :: t 65 | def from_layer(%Layer{} = layer, scope) do 66 | total_time = Layer.total_time(layer) 67 | 68 | %__MODULE__{ 69 | type: layer.type, 70 | name: layer.name, 71 | desc: layer.desc, 72 | scope: scope, 73 | call_count: 1, 74 | total_time: total_time, 75 | exclusive_time: Layer.total_exclusive_time(layer), 76 | min_time: total_time, 77 | max_time: total_time, 78 | backtrace: layer.backtrace 79 | } 80 | end 81 | 82 | # Creates a metric with +type+, +name+, and +number+. +number+ is reported as-is in the payload w/o any unit conversion. 83 | @spec from_sampler_value(any, any, number()) :: t 84 | def from_sampler_value(type, name, number) do 85 | # ensures +number+ is reported as-is. 86 | duration = ScoutApm.Internal.Duration.new(number, :seconds) 87 | 88 | %__MODULE__{ 89 | type: type, 90 | name: name, 91 | call_count: 1, 92 | total_time: duration, 93 | exclusive_time: duration, 94 | min_time: duration, 95 | max_time: duration 96 | } 97 | end 98 | 99 | ####################### 100 | # Updater Functions # 101 | ####################### 102 | 103 | @spec merge(t, t) :: t 104 | def merge(%__MODULE__{} = m1, %__MODULE__{} = m2) do 105 | %__MODULE__{ 106 | type: m1.type, 107 | name: m1.name, 108 | desc: m1.desc, 109 | scope: m1.scope, 110 | backtrace: m1.backtrace, 111 | call_count: m1.call_count + m2.call_count, 112 | total_time: Duration.add(m1.total_time, m2.total_time), 113 | exclusive_time: Duration.add(m1.exclusive_time, m2.exclusive_time), 114 | min_time: Duration.min(m1.min_time, m2.min_time), 115 | max_time: Duration.max(m1.max_time, m2.max_time) 116 | } 117 | end 118 | 119 | ############# 120 | # Queries # 121 | ############# 122 | end 123 | -------------------------------------------------------------------------------- /lib/scout_apm/internal/layer.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.Layer do 2 | @moduledoc """ 3 | Internal to the ScoutAPM agent. 4 | 5 | Represents a single layer during a TrackedRequest 6 | """ 7 | 8 | @type t :: %__MODULE__{ 9 | type: String.t(), 10 | name: nil | String.t(), 11 | desc: nil | String.t(), 12 | backtrace: nil | list(any()), 13 | uri: nil | String.t(), 14 | started_at: number(), 15 | stopped_at: nil | Integer, 16 | scopable: boolean, 17 | manual_duration: nil | ScoutApm.Internal.Duration.t(), 18 | children: list(%__MODULE__{}) 19 | } 20 | 21 | defstruct [ 22 | :type, 23 | :name, 24 | :desc, 25 | :backtrace, 26 | :uri, 27 | :started_at, 28 | :stopped_at, 29 | 30 | # If this is set, ignore started_at -> stopped_at valuse when calculating 31 | # how long this layer ran 32 | :manual_duration, 33 | scopable: true, 34 | children: [] 35 | ] 36 | 37 | alias ScoutApm.Internal.Duration 38 | 39 | ################## 40 | # Construction # 41 | ################## 42 | 43 | @spec new(map) :: __MODULE__.t() 44 | def new(%{type: type, opts: opts} = data) do 45 | started_at = data[:started_at] || NaiveDateTime.utc_now() 46 | name = data[:name] 47 | scopable = Keyword.get(opts, :scopable, true) 48 | 49 | %__MODULE__{ 50 | type: type, 51 | name: name, 52 | started_at: started_at, 53 | scopable: scopable 54 | } 55 | end 56 | 57 | ####################### 58 | # Updater Functions # 59 | ####################### 60 | 61 | # Don't update a name to become nil 62 | def update_name(layer, nil), do: layer 63 | def update_name(layer, name), do: %{layer | name: name} 64 | 65 | def update_stopped_at(layer), do: update_stopped_at(layer, NaiveDateTime.utc_now()) 66 | 67 | def update_stopped_at(layer, stopped_at) do 68 | %{layer | stopped_at: stopped_at} 69 | end 70 | 71 | def update_children(layer, children) do 72 | %{layer | children: children} 73 | end 74 | 75 | def update_desc(layer, desc) do 76 | %{layer | desc: desc} 77 | end 78 | 79 | def update_backtrace(layer, backtrace) do 80 | %{layer | backtrace: backtrace} 81 | end 82 | 83 | def update_uri(layer, uri) do 84 | %{layer | uri: uri} 85 | end 86 | 87 | def set_manual_duration(layer, %Duration{} = duration) do 88 | %{layer | manual_duration: duration} 89 | end 90 | 91 | ################## 92 | # Update Fields # 93 | ################## 94 | 95 | # Updates Layer fields in bulk. See `update_field` functions for fields that permit updates. 96 | def update_fields(layer, []), do: layer 97 | 98 | def update_fields(layer, fields) do 99 | Enum.reduce(fields, layer, fn {key, value}, layer -> 100 | update_field(layer, key, value) 101 | end) 102 | end 103 | 104 | defp update_field(layer, :desc, value), do: update_desc(layer, value) 105 | defp update_field(layer, :backtrace, value), do: update_backtrace(layer, value) 106 | 107 | ############# 108 | # Queries # 109 | ############# 110 | 111 | def total_time(layer) do 112 | case layer.manual_duration do 113 | nil -> 114 | NaiveDateTime.diff(layer.stopped_at, layer.started_at, :microsecond) 115 | |> Duration.new(:microseconds) 116 | 117 | %Duration{} -> 118 | layer.manual_duration 119 | end 120 | end 121 | 122 | def total_child_time(layer) do 123 | Enum.reduce(layer.children, Duration.zero(), fn child, acc -> 124 | Duration.add(acc, total_time(child)) 125 | end) 126 | end 127 | 128 | def total_exclusive_time(layer) do 129 | Duration.subtract(total_time(layer), total_child_time(layer)) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/scout_apm/internal/job_record.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.JobRecord do 2 | @moduledoc """ 3 | Stores a single or multiple runs of a background job. 4 | Both metadata ("queue" and "name"), and metrics ("total time", "metrics") 5 | """ 6 | 7 | alias ScoutApm.MetricSet 8 | alias ScoutApm.Internal.Metric 9 | alias ScoutApm.Internal.Layer 10 | alias ScoutApm.Internal.Duration 11 | 12 | @type t :: %__MODULE__{ 13 | queue: String.t(), 14 | name: String.t(), 15 | count: non_neg_integer, 16 | errors: non_neg_integer, 17 | total_time: ApproximateHistogram.t(), 18 | exclusive_time: ApproximateHistogram.t(), 19 | metrics: MetricSet.t() 20 | } 21 | 22 | defstruct [ 23 | :queue, 24 | :name, 25 | :count, 26 | :errors, 27 | :total_time, 28 | :exclusive_time, 29 | :metrics 30 | ] 31 | 32 | ################## 33 | # Construction # 34 | ################## 35 | 36 | @spec from_layer(Layer.t(), any) :: t 37 | @doc """ 38 | Given a Job layer (probably the root-layer of a TrackedRequest), turn it 39 | into a JobRecord, with fully populated metrics and timing info 40 | """ 41 | def from_layer(%Layer{type: type} = layer, scope) when type == "Job" do 42 | queue_name = "default" 43 | 44 | %__MODULE__{ 45 | queue: queue_name, 46 | name: layer.name, 47 | count: 1, 48 | errors: 0, 49 | total_time: 50 | ApproximateHistogram.add( 51 | ApproximateHistogram.new(), 52 | layer |> Layer.total_time() |> Duration.as(:seconds) 53 | ), 54 | exclusive_time: 55 | ApproximateHistogram.add( 56 | ApproximateHistogram.new(), 57 | layer |> Layer.total_exclusive_time() |> Duration.as(:seconds) 58 | ), 59 | metrics: create_metrics(layer, scope, MetricSet.new()) 60 | } 61 | end 62 | 63 | # Depth-first walk of the layer tree, diving all the way to the leaf 64 | # nodes, then collecting the child and its peers as its walked back up 65 | # the call stack to the parent 66 | defp create_metrics(%Layer{} = layer, scope, %MetricSet{} = metric_set) do 67 | # Collect up all children layers' metrics first 68 | 69 | layer.children 70 | |> Enum.reduce(metric_set, fn child, set -> create_metrics(child, scope, set) end) 71 | 72 | # Then collect this layer's metrics 73 | |> MetricSet.absorb(Metric.from_layer(layer, %{})) 74 | end 75 | 76 | @spec key(t) :: String.t() 77 | def key(%__MODULE__{} = job_record) do 78 | job_record.queue <> "/" <> job_record.name 79 | end 80 | 81 | ####################### 82 | # Updater Functions # 83 | ####################### 84 | 85 | @spec merge(t, t) :: t 86 | def merge( 87 | %__MODULE__{queue: queue1, name: name1} = m1, 88 | %__MODULE__{queue: queue2, name: name2} = m2 89 | ) 90 | when queue1 == queue2 and 91 | name1 == name2 do 92 | %__MODULE__{ 93 | queue: m1.queue, 94 | name: m1.name, 95 | count: m1.count + m2.count, 96 | errors: m1.errors + m2.errors, 97 | total_time: merge_histos(m1.total_time, m2.total_time), 98 | exclusive_time: merge_histos(m1.exclusive_time, m2.exclusive_time), 99 | metrics: MetricSet.merge(m1.metrics, m2.metrics) 100 | } 101 | end 102 | 103 | def increment_errors(record) do 104 | %{record | errors: record.errors + 1} 105 | end 106 | 107 | defp merge_histos(h1, h2) do 108 | h1 109 | |> ApproximateHistogram.to_list() 110 | |> Enum.reduce(h2, fn {val, count}, memo -> 111 | Enum.reduce(1..count, memo, fn _, m -> ApproximateHistogram.add(m, val) end) 112 | end) 113 | end 114 | 115 | ############# 116 | # Queries # 117 | ############# 118 | end 119 | -------------------------------------------------------------------------------- /lib/scout_apm/plugs/controller_timer.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Plugs.ControllerTimer do 2 | alias ScoutApm.Internal.Layer 3 | alias ScoutApm.{Context, TrackedRequest} 4 | @queue_headers ~w(x-queue-start x-request-start) 5 | 6 | def init(default), do: default 7 | 8 | def call(conn, opts) do 9 | if !ignore_uri?(conn.request_path) do 10 | queue_time = get_queue_time_diff_nanoseconds(conn) 11 | TrackedRequest.start_layer("Controller", action_name(conn, opts)) 12 | 13 | if queue_time do 14 | Context.add("scout.queue_time_ns", "#{queue_time}") 15 | end 16 | 17 | conn 18 | |> Plug.Conn.register_before_send(&before_send(&1, opts)) 19 | else 20 | TrackedRequest.ignore() 21 | 22 | conn 23 | end 24 | end 25 | 26 | def before_send(conn, opts) do 27 | full_name = action_name(conn, opts) 28 | uri = "#{conn.request_path}" 29 | 30 | add_ip_context(conn) 31 | maybe_mark_error(conn) 32 | 33 | TrackedRequest.stop_layer(fn layer -> 34 | layer 35 | |> Layer.update_name(full_name) 36 | |> Layer.update_uri(uri) 37 | end) 38 | 39 | conn 40 | end 41 | 42 | @spec ignore_uri?(String.t()) :: boolean() 43 | def ignore_uri?(uri) do 44 | ScoutApm.Config.find(:ignore) 45 | |> Enum.any?(fn prefix -> 46 | String.starts_with?(uri, prefix) 47 | end) 48 | end 49 | 50 | def maybe_mark_error(conn = %{status: 500}) do 51 | TrackedRequest.mark_error() 52 | conn 53 | end 54 | 55 | def maybe_mark_error(conn), do: conn 56 | 57 | @doc """ 58 | Takes a connection, extracts the phoenix controller & action, then manipulates 59 | & cleans it up. 60 | 61 | Returns a string like "PageController#index" 62 | """ 63 | @spec action_name(Plug.Conn.t(), list()) :: String.t() 64 | def action_name(conn, opts) do 65 | action_name = conn.private[:phoenix_action] 66 | include_app_name = Keyword.get(opts, :include_application_name, false) 67 | 68 | conn.private[:phoenix_controller] 69 | |> Module.split() 70 | |> trim_app_module_name(include_app_name) 71 | |> Enum.join(".") 72 | # Append action 73 | |> Kernel.<>("##{action_name}") 74 | end 75 | 76 | defp add_ip_context(conn) do 77 | remote_ip = 78 | case Plug.Conn.get_req_header(conn, "x-forwarded-for") do 79 | [forwarded_ip | _] -> 80 | forwarded_ip 81 | 82 | _ -> 83 | conn.remote_ip 84 | |> Tuple.to_list() 85 | |> Enum.join(".") 86 | end 87 | 88 | Context.add_user(:ip, remote_ip) 89 | end 90 | 91 | defp get_queue_time_diff_nanoseconds(conn) do 92 | unix_now = 93 | DateTime.utc_now() 94 | |> DateTime.to_unix(:millisecond) 95 | 96 | queue_start_ms = 97 | Enum.find_value(@queue_headers, fn header -> 98 | case Plug.Conn.get_req_header(conn, header) do 99 | [timestamp] when is_binary(timestamp) -> 100 | timestamp 101 | 102 | [] -> 103 | nil 104 | end 105 | end) 106 | 107 | with true <- is_binary(queue_start_ms), 108 | {queue_start_ms_unix, ""} <- parse_request_start_time(queue_start_ms) do 109 | (unix_now - queue_start_ms_unix) 110 | |> abs() 111 | |> System.convert_time_unit(:millisecond, :nanosecond) 112 | else 113 | _ -> nil 114 | end 115 | end 116 | 117 | defp parse_request_start_time("t=" <> queue_start_ms) do 118 | Integer.parse(queue_start_ms) 119 | end 120 | 121 | defp parse_request_start_time(queue_start_ms) do 122 | Integer.parse(queue_start_ms) 123 | end 124 | 125 | defp trim_app_module_name(parts, include_app_name) do 126 | if include_app_name do 127 | parts 128 | else 129 | Enum.drop(parts, 1) 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # master 2 | 3 | # 1.0.7 4 | 5 | * Enhancements 6 | * Add URI to request context (#114) 7 | 8 | # 1.0.6 9 | 10 | * Enhancements 11 | * Allow expanding app name in template metrics (#111) 12 | 13 | # 1.0.5 14 | 15 | * Enhancements 16 | * Update CoreAgent to 1.2.6 (#109) 17 | * Send Queue Time as String (#110) 18 | 19 | # 1.0.4 20 | 21 | * Enhancements 22 | * Queue time metric for Nginx (#106) 23 | 24 | # 1.0.3 25 | 26 | * Enhancements 27 | * Update Core Agent default version to v1.2.4 (#105) 28 | 29 | # 1.0.2 30 | 31 | * Bug Fixes 32 | * Send TrackedRequest error as a TagRequest (#104) 33 | * Ensure git\_sha is not nil (#104) 34 | 35 | # 1.0.1 36 | 37 | * Enhancements 38 | * Better core agent platform detection (#101) 39 | 40 | * Bug Fixes 41 | * Do not try to start core agent or send messages with no key (#102) 42 | 43 | 44 | # 1.0.0 45 | 46 | * Enhancements 47 | * Send platform in metadata (#92) 48 | * Use Core Agent to gather and transmit metrics (#93) 49 | * Use Jason instead of Poison for JSON encoding (#96) 50 | * Add Mix task to check configuration (#97) 51 | * Queue time metric and renaming capability for transactions (#98) 52 | 53 | * Bug Fixes 54 | * Fix error in converting list to string (#90) 55 | * Fix mismatched layers during ignored transaction(#95) 56 | 57 | * Breaking Changes 58 | * Deprecated tracing `@transaction` and `@timing` module attributes have been removed 59 | 60 | # 0.4.15 61 | 62 | * Fix Ecto 2 support (#88) 63 | 64 | # 0.4.14 65 | 66 | * Support Telemetry 0.3.0/0.4.0 and Ecto 3.0/3.1 (#84) 67 | 68 | # 0.4.13 69 | 70 | * Support Instrumenting multiple Ecto repos (#81) 71 | 72 | # 0.4.12 73 | 74 | * Add ScoutApm.TrackedRequest.ignore() to immediately ignore and stop any 75 | additional data collection for the current Transaction. 76 | 77 | # 0.4.11 78 | 79 | * Fix Ecto Telemetry when Repo module is deeply nested. 80 | 81 | # 0.4.10 82 | 83 | * Fix deprecation warnings from newer Elixir versions 84 | 85 | # 0.4.9 86 | 87 | * Enhancements 88 | * Make `action_name` function public for use in instrumenting chunked HTTP responses (#70) 89 | 90 | # 0.4.8 91 | 92 | * Enhancements 93 | * Ecto 3 support 94 | 95 | # 0.4.7 96 | 97 | * Enhancements 98 | * Add Deploy Tracking 99 | * Attach Git SHA to Traces 100 | 101 | # 0.4.6 102 | 103 | * Bug Fixes 104 | * Fix cache start order (#64) 105 | 106 | # 0.4.5 107 | 108 | * Bug Fixes 109 | * Set hostname on slow transactions (#61) 110 | * Avoid raising on layer mismatch (#63) 111 | 112 | # 0.4.4 113 | 114 | * Bug Fixes 115 | * Do not raise when Ecto.LogEntry has nil query\_time (#58) 116 | 117 | # 0.4.3 118 | 119 | * Enhancements 120 | * Track Error Rates (#56) 121 | * Bug Fixes 122 | * Fix compile warning if project is not using PhoenixSlime (#56) 123 | 124 | # 0.4.2 125 | 126 | * Enhancements 127 | * Added ability to instrument Slime templates (#54) 128 | 129 | # 0.4.1 130 | 131 | * Enhancements 132 | * Added `deftiming` and `deftransaction` macros to ScoutApm.Tracing (#52) 133 | * Rename DevTrace.Store to DirectAnalysisStore and always enable (#51) 134 | 135 | # 0.4.0 136 | * Enhancements 137 | * Silence logging when Scout is not configured (#46) 138 | * Allow configuration of ignored routes (#45) 139 | * Remove Configuration module GenServer (#47) 140 | * Bug Fixes 141 | * Prevent error when popping element from a TrackedRequest (#44) 142 | 143 | # 0.3.3 144 | 145 | * Fix bug serializing histograms in certain cases 146 | 147 | # 0.3.0 148 | 149 | * Added ability to instrument background job transactions 150 | * Added instrumentation via module attributes 151 | * Added instrumentation via `transaction/4` and `timing/4` macros 152 | * Deprecated `instrument/4` 153 | * Wrapped `transaction/4` and `timing/4` inside `try` blocks so an exception in instrumented code still tracks the associated transaction / timing. 154 | -------------------------------------------------------------------------------- /lib/scout_apm/scored_item_set.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.ScoredItemSet do 2 | @moduledoc """ 3 | A capped set type that has a few rules on inclusion. 4 | 5 | When you add an item, it must be a tuple of shape: {{:score, integer, key}, item} 6 | 7 | Where the key uniquely identifies the item, as a string. The score is a 8 | unitless relative "value" of this item, and then the item itself can be any 9 | structure 10 | 11 | Only the highest score of each key is kept, no duplicates, even if the set has "room" for it. 12 | 13 | Only the highest scores will be kept when at capacity. Adding a new element 14 | may or may result in the new item evicting an old one, or being simply 15 | dropped, based on the comparison of the scores. 16 | """ 17 | 18 | @type t :: %__MODULE__{ 19 | options: __MODULE__.options(), 20 | data: %{any() => scored_item} 21 | } 22 | 23 | @type options :: %{ 24 | max_count: pos_integer() 25 | } 26 | 27 | @type key :: String.t() 28 | @type score :: {:score, number(), key} 29 | @type scored_item :: {score, any()} 30 | 31 | defstruct [ 32 | :options, 33 | :data 34 | ] 35 | 36 | @default_max_count 10 37 | 38 | @spec new() :: t 39 | def new() do 40 | %__MODULE__{ 41 | options: %{ 42 | max_count: @default_max_count 43 | }, 44 | data: %{} 45 | } 46 | end 47 | 48 | @spec set_max_count(t, pos_integer()) :: t 49 | def set_max_count(%__MODULE__{} = set, max_count) do 50 | %{set | options: %{set.options | max_count: max_count}} 51 | end 52 | 53 | @spec size(t) :: non_neg_integer() 54 | def size(%__MODULE__{} = set) do 55 | Enum.count(set.data) 56 | end 57 | 58 | @spec absorb(t, scored_item) :: t 59 | def absorb(%__MODULE__{} = set, {{_, score, key}, _} = scored_item) do 60 | case Map.fetch(set.data, key) do 61 | # If the item exists, compare the new vs old scored_items and the winner 62 | # gets put into the data map. Size of the data map doesn't change. 63 | {:ok, {{_, existing_score, _}, _} = existing_scored_item} -> 64 | winner = 65 | if existing_score < score do 66 | scored_item 67 | else 68 | existing_scored_item 69 | end 70 | 71 | %{set | data: %{set.data | key => winner}} 72 | 73 | # If this key doesn't yet exist, then we simply add it if there's room 74 | # or if not, we have to figure out if it's high enough score to evict 75 | # another, and then do the eviction 76 | :error -> 77 | if size(set) < set.options.max_count do 78 | %{ 79 | set 80 | | data: 81 | set.data 82 | |> Map.put(key, scored_item) 83 | } 84 | else 85 | absorb_at_capacity(set, scored_item) 86 | end 87 | end 88 | end 89 | 90 | @spec to_list(t) :: list(scored_item) 91 | def to_list(%__MODULE__{} = set) do 92 | Enum.map(set.data, fn {_, v} -> v end) 93 | end 94 | 95 | @spec to_list(t, :without_scores) :: list(scored_item) 96 | def to_list(%__MODULE__{} = set, :without_scores) do 97 | Enum.map(set.data, fn {_, {_, v}} -> v end) 98 | end 99 | 100 | @spec absorb_at_capacity(t, scored_item) :: t 101 | defp absorb_at_capacity(%__MODULE__{} = set, {{:score, score, key}, _} = scored_item) do 102 | {_, {{_, low_score, low_key}, _}} = Enum.min_by(set.data, fn {_key, {{_, s, _}, _}} -> s end) 103 | 104 | if score < low_score do 105 | # This score was too low to break into the set, so no update is needed. 106 | set 107 | else 108 | # This score is higher than the lowest in the set, so we'll evict the 109 | # lowest, and replace it with this one. 110 | %{ 111 | set 112 | | data: 113 | set.data 114 | |> Map.delete(low_key) 115 | |> Map.put(key, scored_item) 116 | } 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/scout_apm/tracked_request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TrackedRequestTest do 2 | use ExUnit.Case, async: false 3 | import ExUnit.CaptureLog 4 | alias ScoutApm.TrackedRequest 5 | 6 | setup do 7 | ScoutApm.TestCollector.clear_messages() 8 | :ok 9 | end 10 | 11 | describe "new/0" do 12 | test "creates a TrackedRequest" do 13 | assert ScoutApm.TrackedRequest == TrackedRequest.new().__struct__ 14 | end 15 | 16 | test "accepts a function as an argument" do 17 | assert ScoutApm.TrackedRequest == TrackedRequest.new(fn r -> r end).__struct__ 18 | end 19 | end 20 | 21 | test "starting a layer, then stopping calls the track function" do 22 | pid = self() 23 | 24 | TrackedRequest.new(fn r -> 25 | ScoutApm.Command.Batch.from_tracked_request(r) 26 | |> ScoutApm.Command.message() 27 | 28 | send(pid, {:complete, r}) 29 | end) 30 | |> TrackedRequest.start_layer("foo", "bar", []) 31 | |> TrackedRequest.stop_layer() 32 | 33 | receive do 34 | {:complete, r} -> 35 | assert ScoutApm.TrackedRequest == r.__struct__ 36 | 37 | _ -> 38 | refute true, "Unexpected message" 39 | after 40 | 1000 -> 41 | refute true, "Timed out message" 42 | end 43 | end 44 | 45 | test "the root layer is whichever layer was started first" do 46 | pid = self() 47 | 48 | TrackedRequest.new(fn r -> send(pid, {:complete, r}) end) 49 | |> TrackedRequest.start_layer("foo", "bar", []) 50 | |> TrackedRequest.start_layer("nested", "x", []) 51 | |> TrackedRequest.stop_layer() 52 | |> TrackedRequest.stop_layer() 53 | 54 | receive do 55 | {:complete, r} -> 56 | assert r.root_layer.type == "foo" 57 | assert r.root_layer.name == "bar" 58 | 59 | _ -> 60 | refute true, "Unexpected message" 61 | after 62 | 1000 -> 63 | refute true, "Timed out message" 64 | end 65 | end 66 | 67 | test "children get attached correctly" do 68 | pid = self() 69 | 70 | TrackedRequest.new(fn r -> send(pid, {:complete, r}) end) 71 | |> TrackedRequest.start_layer("foo", "bar", []) 72 | |> TrackedRequest.start_layer("nested", "x1", []) 73 | |> TrackedRequest.start_layer("nested2", "y", []) 74 | |> TrackedRequest.stop_layer() 75 | |> TrackedRequest.stop_layer() 76 | |> TrackedRequest.start_layer("nested", "x2", []) 77 | |> TrackedRequest.stop_layer() 78 | |> TrackedRequest.stop_layer() 79 | 80 | receive do 81 | {:complete, r} -> 82 | assert [c1, c2] = r.root_layer.children 83 | assert c1.name == "x1" 84 | assert c2.name == "x2" 85 | assert List.first(c1.children).name == "y" 86 | 87 | _ -> 88 | refute true, "Unexpected message" 89 | after 90 | 1000 -> 91 | refute true, "Timed out message" 92 | end 93 | end 94 | 95 | test "Starting a layer w/o an explicit record saves it in the process dictionary" do 96 | TrackedRequest.start_layer("foo", "bar") 97 | assert ScoutApm.TrackedRequest == Process.get(:scout_apm_request).__struct__ 98 | end 99 | 100 | test "Correctly discards and logs warning when layer is not stopped" do 101 | Application.put_all_env(scout_apm: [monitor: true, key: "abc123"]) 102 | pid = self() 103 | 104 | assert capture_log(fn -> 105 | TrackedRequest.new(fn r -> send(pid, {:complete, r}) end) 106 | |> TrackedRequest.stop_layer() 107 | end) =~ "Scout Layer mismatch" 108 | 109 | Application.delete_env(:scout_apm, :monitor) 110 | Application.delete_env(:scout_apm, :key) 111 | end 112 | 113 | test "rename creates a TagRequest" do 114 | TrackedRequest.start_layer("foo", "bar", []) 115 | TrackedRequest.start_layer("nested", "x", []) 116 | TrackedRequest.stop_layer() 117 | TrackedRequest.rename("testing-rename") 118 | TrackedRequest.stop_layer() 119 | 120 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 121 | 122 | assert Enum.any?(commands, fn command -> 123 | map = Map.get(command, :TagRequest) 124 | 125 | map && Map.get(map, :tag) == "transaction.name" && 126 | Map.get(map, :value) == "testing-rename" 127 | end) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/scout_apm/metric_set.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.MetricSet do 2 | @moduledoc """ 3 | A way to absorb & combine metrics into a single set, keeping track of min/max/count, etc. 4 | 5 | While this is just a map underneath, treat it like an opaque data type. 6 | """ 7 | 8 | @type t :: %__MODULE__{ 9 | data: map, 10 | options: options, 11 | types: MapSet.t() 12 | } 13 | 14 | @type options :: %{ 15 | collapse_all: boolean(), 16 | compare_desc: boolean(), 17 | max_types: non_neg_integer() 18 | } 19 | 20 | defstruct [ 21 | :options, 22 | :data, 23 | :types 24 | ] 25 | 26 | # Maximum number of unique types. This is far larger than what you'd really 27 | # want, and only acts as a safety valve. If you're doing custom 28 | # instrumentation, keep the metric type field very simple. "HTTP", "JSON", 29 | # and similar. 30 | @max_types 100 31 | 32 | alias ScoutApm.Internal.Metric 33 | 34 | @default_options %{ 35 | collapse_all: false, 36 | compare_desc: false, 37 | max_types: @max_types 38 | } 39 | 40 | @spec new() :: ScoutApm.MetricSet.t() 41 | def new, do: new(@default_options) 42 | 43 | @spec new(map()) :: t 44 | def new(options) do 45 | resolved_options = Map.merge(@default_options, options) 46 | 47 | %__MODULE__{ 48 | options: resolved_options, 49 | data: %{}, 50 | types: MapSet.new() 51 | } 52 | end 53 | 54 | @spec absorb(t, Metric.t()) :: t 55 | @doc """ 56 | Add this metric to this metric set. 57 | 58 | As a safety valve in the agent, 59 | this skips adding if we've reached the limit of unique 'type' values 60 | in this set. Since 'type' is something like 'Ecto' or 'Controller', 61 | it's very unlikely that this safety valve ever gets hit in normal 62 | practice, but instead is designed to protect people from accidentally 63 | varying their custom instrumentation types. 64 | """ 65 | def absorb(%__MODULE__{} = metric_set, %Metric{} = metric) do 66 | if under_type_limit?(metric_set) do 67 | metric_set 68 | |> absorb_no_type_limit(metric) 69 | |> register_type(metric.type) 70 | else 71 | # Don't actually absorb, this is over limit. 72 | ScoutApm.AgentNote.note({:metric_type, :over_limit, metric_set.options.max_types}) 73 | metric_set 74 | end 75 | end 76 | 77 | @spec absorb_all(t, list(Metric.t())) :: t 78 | def absorb_all(%__MODULE__{} = metric_set, metrics) when is_list(metrics) do 79 | Enum.reduce( 80 | metrics, 81 | metric_set, 82 | fn metric, set -> absorb(set, metric) end 83 | ) 84 | end 85 | 86 | @spec merge(t, t) :: t 87 | def merge(%__MODULE__{} = set1, %__MODULE__{} = set2) do 88 | absorb_all(set1, to_list(set2)) 89 | end 90 | 91 | # Ditches the key part, and just returns the aggregate metric 92 | @spec to_list(t) :: list(Metric.t()) 93 | def to_list(%__MODULE__{} = metric_set) do 94 | metric_set.data 95 | |> Map.to_list() 96 | |> Enum.map(fn {_, x} -> x end) 97 | end 98 | 99 | ##################### 100 | # Private Helpers # 101 | ##################### 102 | 103 | # Always with the full key (type + name) 104 | # Then optionally with the desc field too 105 | defp scoped_key(metric, %{compare_desc: compare_desc}) do 106 | case compare_desc do 107 | true -> 108 | "#{metric.type}/#{metric.name}/scope/#{metric.scope[:type]}/#{metric.scope[:name]}/desc/#{ 109 | metric.desc 110 | }" 111 | 112 | false -> 113 | "#{metric.type}/#{metric.name}/scope/#{metric.scope[:type]}/#{metric.scope[:name]}" 114 | end 115 | end 116 | 117 | defp stripped_metric(%Metric{} = metric, %{compare_desc: compare_desc}) do 118 | case compare_desc do 119 | true -> metric 120 | false -> %Metric{metric | desc: nil} 121 | end 122 | end 123 | 124 | # Have we hit the safety-valve limit of types? 125 | defp under_type_limit?(%__MODULE__{} = metric_set) do 126 | MapSet.size(metric_set.types) < metric_set.options.max_types 127 | end 128 | 129 | defp absorb_no_type_limit(%__MODULE__{} = metric_set, %Metric{} = metric) do 130 | scoped_key = scoped_key(metric, metric_set.options) 131 | 132 | new_data = 133 | Map.update(metric_set.data, scoped_key, metric, fn m2 -> 134 | Metric.merge(stripped_metric(metric, metric_set.options), m2) 135 | end) 136 | 137 | %{metric_set | data: new_data} 138 | end 139 | 140 | defp register_type(%__MODULE__{} = metric_set, type) do 141 | %{metric_set | types: MapSet.put(metric_set.types, type)} 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Scout Software Agent License 2 | 3 | Subject to and conditioned upon your continued compliance with the terms and conditions of this license, Zimuth, Inc. grants you a non-exclusive, non-sublicensable and non-transferable, limited license to install, use and run one copy of this software on each of your and your affiliate’s computers. This license also grants you the limited right to distribute verbatim copies of this software and documentation to third parties provided the software and documentation will (a) remain the exclusive property of Zimuth; (b) be subject to the terms and conditions of this license; and (c) include a complete and unaltered copy of this license and all other copyright or other intellectual property rights notices contained in the original. 4 | 5 | The software includes the open-source components listed below. Any use of the open-source components by you shall be governed by, and subject to, the terms and conditions of the applicable open-source licenses. 6 | 7 | Except as this license expressly permits, you may not: 8 | 9 | * copy the software, in whole or in part; 10 | * modify, correct, adapt, translate, enhance or otherwise prepare derivative works or improvements of the software; 11 | * sell, sublicense, assign, distribute, publish, transfer or otherwise make available the software to any person or entity; 12 | * remove, delete, efface, alter, obscure, translate, combine, supplement or otherwise change any trademarks, terms of the documentation, warranties, disclaimers, or intellectual property rights, or other symbols, notices, marks or serial numbers on or relating to any copy of the software or documentation; or 13 | use the software in any manner or for any purpose that infringes, misappropriates or otherwise violates any intellectual property right or other right of any person or entity, or that violates any applicable law; 14 | 15 | By using the software, you acknowledge and agree that: 16 | 17 | * the software and documentation are licensed, not sold, to you by Zimuth and you do not and will not have or acquire under or in connection with this license any ownership interest in the software or documentation, or in any related intellectual property rights; and 18 | * Zimuth will remain the sole and exclusive owner of all right, title and interest in and to the software and documentation, including all related intellectual property rights, subject only to the rights of third parties in open-source components and the limited license granted to you under this license; and 19 | 20 | Except for the limited rights and licenses expressly granted to you under this agreement, nothing in this license grants, by implication, waiver, estoppel or otherwise, to you or any third party any intellectual property rights or other right, title, or interest in or to any of the software or documentation. 21 | 22 | This license shall be automatically terminated and revoked if you exceed the scope or violate any terms and conditions of this license. 23 | 24 | ALL LICENSED SOFTWARE, DOCUMENTATION AND OTHER PRODUCTS, INFORMATION, MATERIALS AND SERVICES PROVIDED BY ZIMUTH ARE PROVIDED HERE “AS IS.” ZIMUTH DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHER (INCLUDING ALL WARRANTIES ARISING FROM COURSE OF DEALING, USAGE OR TRADE PRACTICE), AND SPECIFICALLY DISCLAIMS ALL IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. WITHOUT LIMITING THE FOREGOING, ZIMUTH MAKES NO WARRANTY OF ANY KIND THAT THE LICENSED SOFTWARE OR DOCUMENTATION, OR ANY OTHER LICENSOR OR THIRD-PARTY GOODS, SERVICES, TECHNOLOGIES OR MATERIALS, WILL MEET YOUR REQUIREMENTS, OPERATE WITHOUT INTERRUPTION, ACHIEVE ANY INTENDED RESULT, BE COMPATIBLE OR WORK WITH ANY OTHER GOODS, SERVICES, TECHNOLOGIES OR MATERIALS (INCLUDING ANY SOFTWARE, HARDWARE, SYSTEM OR NETWORK) EXCEPT IF AND TO THE EXTENT EXPRESSLY SET FORTH IN THE DOCUMENTATION, OR BE SECURE, ACCURATE, COMPLETE, FREE OF HARMFUL CODE OR ERROR FREE. ALL OPEN-SOURCE COMPONENTS AND OTHER THIRD-PARTY MATERIALS ARE PROVIDED “AS IS” AND ANY REPRESENTATION OR WARRANTY OF OR CONCERNING ANY OF THEM IS STRICTLY BETWEEN LICENSEE AND THE THIRD-PARTY OWNER OR DISTRIBUTOR OF SUCH OPEN-SOURCE COMPONENTS AND THIRD-PARTY MATERIALS. 25 | 26 | IN NO EVENT WILL ZIMUTH BE LIABLE UNDER OR IN CONNECTION WITH THIS LICENSE OR ITS SUBJECT MATTER UNDER ANY LEGAL OR EQUITABLE THEORY, INCLUDING BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY AND OTHERWISE, FOR ANY CONSEQUENTIAL, INCIDENTAL, INDIRECT, EXEMPLARY, SPECIAL, ENHANCED OR PUNITIVE DAMAGES, IN EACH CASE REGARDLESS OF WHETHER SUCH PERSONS WERE ADVISED OF THE POSSIBILITY OF SUCH LOSSES OR DAMAGES OR SUCH LOSSES OR DAMAGES WERE OTHERWISE FORESEEABLE, AND NOTWITHSTANDING THE FAILURE OF ANY AGREED OR OTHER REMEDY OF ITS ESSENTIAL PURPOSE. 27 | 28 | -------------------------------------------------------------------------------- /test/scout_apm/scored_item_set_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.ScoredItemSetTest do 2 | use ExUnit.Case, async: true 3 | alias ScoutApm.ScoredItemSet 4 | 5 | describe "size/1" do 6 | test "a new set is size 0" do 7 | assert 0 == ScoredItemSet.size(ScoredItemSet.new()) 8 | end 9 | 10 | test "a set with an item is size 1" do 11 | assert 1 == 12 | ScoredItemSet.size( 13 | ScoredItemSet.new() 14 | |> ScoredItemSet.absorb(item()) 15 | ) 16 | end 17 | 18 | test "a set with lots of items caps at max_size" do 19 | set = 20 | 1..100 21 | |> Enum.reduce( 22 | ScoredItemSet.new(), 23 | fn i, set -> ScoredItemSet.absorb(set, item(i, "key#{i}")) end 24 | ) 25 | 26 | assert 10 == ScoredItemSet.size(set) 27 | end 28 | end 29 | 30 | describe "absorb/2" do 31 | test "absorbing an item with the same name has the highest score stay" do 32 | assert [{_, {"key", 20, _}}] = 33 | ScoredItemSet.to_list( 34 | ScoredItemSet.new() 35 | |> ScoredItemSet.absorb(item(10, "key")) 36 | |> ScoredItemSet.absorb(item(20, "key")) 37 | |> ScoredItemSet.absorb(item(0, "key")) 38 | |> ScoredItemSet.absorb(item(15, "key")) 39 | ) 40 | end 41 | 42 | test "absorbing different items doesn't cause competition" do 43 | assert [ 44 | {_, {"key1", 10, _}}, 45 | {_, {"key2", 20, _}}, 46 | {_, {"key3", 05, _}}, 47 | {_, {"key4", 15, _}} 48 | ] = 49 | ScoredItemSet.to_list( 50 | ScoredItemSet.new() 51 | |> ScoredItemSet.absorb(item(10, "key1")) 52 | |> ScoredItemSet.absorb(item(20, "key2")) 53 | |> ScoredItemSet.absorb(item(05, "key3")) 54 | |> ScoredItemSet.absorb(item(15, "key4")) 55 | ) 56 | end 57 | 58 | test "with a full set, low scored items don't get added" do 59 | assert [ 60 | {_, {"key01", 01, _}}, 61 | {_, {"key02", 02, _}}, 62 | {_, {"key03", 03, _}}, 63 | {_, {"key04", 04, _}}, 64 | {_, {"key05", 05, _}}, 65 | {_, {"key06", 06, _}}, 66 | {_, {"key07", 07, _}}, 67 | {_, {"key08", 08, _}}, 68 | {_, {"key09", 09, _}}, 69 | {_, {"key10", 10, _}} 70 | ] = 71 | ScoredItemSet.to_list( 72 | ScoredItemSet.new() 73 | |> ScoredItemSet.absorb(item(01, "key01")) 74 | |> ScoredItemSet.absorb(item(02, "key02")) 75 | |> ScoredItemSet.absorb(item(03, "key03")) 76 | |> ScoredItemSet.absorb(item(04, "key04")) 77 | |> ScoredItemSet.absorb(item(05, "key05")) 78 | |> ScoredItemSet.absorb(item(06, "key06")) 79 | |> ScoredItemSet.absorb(item(07, "key07")) 80 | |> ScoredItemSet.absorb(item(08, "key08")) 81 | |> ScoredItemSet.absorb(item(09, "key09")) 82 | |> ScoredItemSet.absorb(item(10, "key10")) 83 | |> ScoredItemSet.absorb(item(0, "key11")) 84 | ) 85 | end 86 | 87 | test "with a full set, high scored items evict another item" do 88 | assert [ 89 | {_, {"key02", 02, _}}, 90 | {_, {"key03", 03, _}}, 91 | {_, {"key04", 04, _}}, 92 | {_, {"key05", 05, _}}, 93 | {_, {"key06", 06, _}}, 94 | {_, {"key07", 07, _}}, 95 | {_, {"key08", 08, _}}, 96 | {_, {"key09", 09, _}}, 97 | {_, {"key10", 10, _}}, 98 | {_, {"key11", 20, _}} 99 | ] = 100 | ScoredItemSet.to_list( 101 | ScoredItemSet.new() 102 | |> ScoredItemSet.absorb(item(01, "key01")) 103 | |> ScoredItemSet.absorb(item(02, "key02")) 104 | |> ScoredItemSet.absorb(item(03, "key03")) 105 | |> ScoredItemSet.absorb(item(04, "key04")) 106 | |> ScoredItemSet.absorb(item(05, "key05")) 107 | |> ScoredItemSet.absorb(item(06, "key06")) 108 | |> ScoredItemSet.absorb(item(07, "key07")) 109 | |> ScoredItemSet.absorb(item(08, "key08")) 110 | |> ScoredItemSet.absorb(item(09, "key09")) 111 | |> ScoredItemSet.absorb(item(10, "key10")) 112 | |> ScoredItemSet.absorb(item(20, "key11")) 113 | ) 114 | end 115 | end 116 | 117 | def item(score \\ 10, key \\ "key") do 118 | {{:score, score, key}, {key, score, "other stuff value"}} 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/scout_apm/tracing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TracingTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | ScoutApm.TestCollector.clear_messages() 6 | :ok 7 | end 8 | 9 | describe "deftransaction" do 10 | test "creates histograms with sensible defaults" do 11 | assert ScoutApm.TestTracing.add_one(1) == 2 12 | assert ScoutApm.TestTracing.add_one(1.0) == 2.0 13 | 14 | [%{BatchCommand: %{commands: commands1}}, %{BatchCommand: %{commands: commands2}}] = 15 | ScoutApm.TestCollector.messages() 16 | 17 | assert Enum.any?(commands1, fn command -> 18 | map = Map.get(command, :StartSpan) 19 | 20 | map && 21 | Map.get(map, :operation) == 22 | "Job/ScoutApm.TestTracing.add_one(integer) when is_integer(integer)" 23 | end) 24 | 25 | assert Enum.any?(commands2, fn command -> 26 | map = Map.get(command, :StartSpan) 27 | 28 | map && 29 | Map.get(map, :operation) == 30 | "Job/ScoutApm.TestTracing.add_one(number) when is_float(number)" 31 | end) 32 | end 33 | 34 | test "creates histograms with overridden type and name" do 35 | assert ScoutApm.TestTracing.add_two(1) == 3 36 | assert ScoutApm.TestTracing.add_two(1.0) == 3.0 37 | 38 | [%{BatchCommand: %{commands: commands1}}, %{BatchCommand: %{commands: commands2}}] = 39 | ScoutApm.TestCollector.messages() 40 | 41 | assert Enum.any?(commands1, fn command -> 42 | map = Map.get(command, :StartSpan) 43 | map && Map.get(map, :operation) == "Controller/test1" 44 | end) 45 | 46 | assert Enum.any?(commands2, fn command -> 47 | map = Map.get(command, :StartSpan) 48 | map && Map.get(map, :operation) == "Job/test2" 49 | end) 50 | end 51 | end 52 | 53 | describe "deftiming" do 54 | test "creates histograms with sensible defaults" do 55 | assert ScoutApm.TestTracing.add_three(1) == 4 56 | assert ScoutApm.TestTracing.add_three(1.0) == 4 57 | 58 | [%{BatchCommand: %{commands: commands1}}, %{BatchCommand: %{commands: commands2}}] = 59 | ScoutApm.TestCollector.messages() 60 | 61 | assert Enum.any?(commands1, fn command -> 62 | map = Map.get(command, :StartSpan) 63 | 64 | map && 65 | Map.get(map, :operation) == 66 | "Custom/ScoutApm.TestTracing.add_three(integer) when is_integer(integer)" 67 | end) 68 | 69 | assert Enum.any?(commands2, fn command -> 70 | map = Map.get(command, :StartSpan) 71 | 72 | map && 73 | Map.get(map, :operation) == 74 | "Custom/ScoutApm.TestTracing.add_three(number) when is_float(number)" 75 | end) 76 | end 77 | 78 | test "creates histograms with overridden type and name" do 79 | assert ScoutApm.TestTracing.add_four(1) == 5 80 | assert ScoutApm.TestTracing.add_four(1.0) == 5.0 81 | 82 | [%{BatchCommand: %{commands: commands1}}, %{BatchCommand: %{commands: commands2}}] = 83 | ScoutApm.TestCollector.messages() 84 | 85 | assert Enum.any?(commands1, fn command -> 86 | map = Map.get(command, :StartSpan) 87 | map && Map.get(map, :operation) == "Adding/add integers" 88 | end) 89 | 90 | assert Enum.any?(commands2, fn command -> 91 | map = Map.get(command, :StartSpan) 92 | map && Map.get(map, :operation) == "Controller/add floats" 93 | end) 94 | end 95 | end 96 | 97 | test "marks as error" do 98 | assert ScoutApm.TestTracing.add_one_with_error(2) == 3 99 | assert ScoutApm.TestTracing.add_one_with_error(2) == 3 100 | 101 | [%{BatchCommand: %{commands: commands1}}, %{BatchCommand: %{commands: commands2}}] = 102 | ScoutApm.TestCollector.messages() 103 | 104 | assert Enum.any?(commands1, fn command -> 105 | map = Map.get(command, :TagRequest) 106 | map && Map.get(map, :tag) == "error" && Map.get(map, :value) == "true" 107 | end) 108 | 109 | assert Enum.any?(commands2, fn command -> 110 | map = Map.get(command, :TagRequest) 111 | map && Map.get(map, :tag) == "error" && Map.get(map, :value) == "true" 112 | end) 113 | end 114 | 115 | test "marks as ignored" do 116 | assert ScoutApm.TestTracing.add_five(2) == 7 117 | assert ScoutApm.TestTracing.add_five(3) == 8 118 | 119 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 120 | 121 | assert Enum.any?(commands, fn command -> 122 | map = Map.get(command, :StartSpan) 123 | map && Map.get(map, :operation) == "Job/ScoutApm.TestTracing.add_five(number)" 124 | end) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/scout_apm/internal/web_trace.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Internal.WebTrace do 2 | @moduledoc """ 3 | A record of a single trace. 4 | """ 5 | 6 | alias ScoutApm.MetricSet 7 | alias ScoutApm.Internal.Duration 8 | alias ScoutApm.Internal.Metric 9 | alias ScoutApm.Internal.Layer 10 | alias ScoutApm.Internal.Context 11 | alias ScoutApm.ScopeStack 12 | 13 | defstruct [ 14 | :type, 15 | :name, 16 | :total_call_time, 17 | :metrics, 18 | :uri, 19 | :time, 20 | :hostname, 21 | :git_sha, 22 | :contexts, 23 | 24 | # TODO: Does anybody ever set this Score field? 25 | :score 26 | ] 27 | 28 | @type t :: %__MODULE__{ 29 | type: String.t(), 30 | name: String.t(), 31 | total_call_time: Duration.t(), 32 | metrics: list(Metric.t()), 33 | uri: nil | String.t(), 34 | time: any, 35 | hostname: String.t(), 36 | git_sha: String.t(), 37 | contexts: Context.t(), 38 | score: number() 39 | } 40 | 41 | # @spec new(String.t, String.t, Duration.t, list(Metric.t), String.t, Context.t, any, String.t, String.t | nil) :: t 42 | def new(type, name, duration, metrics, uri, contexts, time, hostname, git_sha) do 43 | %__MODULE__{ 44 | type: type, 45 | name: name, 46 | total_call_time: duration, 47 | metrics: metrics, 48 | uri: uri, 49 | time: time, 50 | hostname: hostname, 51 | git_sha: git_sha, 52 | contexts: contexts, 53 | 54 | # TODO: Store the trace's own score 55 | score: 0 56 | } 57 | end 58 | 59 | # Creates a Trace struct from a `TracedRequest`. 60 | def from_tracked_request(tracked_request) do 61 | root_layer = tracked_request.root_layer 62 | 63 | duration = Layer.total_time(root_layer) 64 | 65 | uri = root_layer.uri 66 | 67 | contexts = tracked_request.contexts 68 | 69 | time = DateTime.utc_now() |> DateTime.to_iso8601() 70 | hostname = ScoutApm.Cache.hostname() 71 | git_sha = ScoutApm.Cache.git_sha() 72 | 73 | # Metrics scoped & stuff. Distinguished by type, name, scope, desc 74 | metric_set = 75 | create_trace_metrics( 76 | root_layer, 77 | ScopeStack.new(), 78 | MetricSet.new(%{compare_desc: true, collapse_all: true}) 79 | ) 80 | 81 | new( 82 | root_layer.type, 83 | root_layer.name, 84 | duration, 85 | MetricSet.to_list(metric_set), 86 | uri, 87 | contexts, 88 | time, 89 | hostname, 90 | git_sha 91 | ) 92 | end 93 | 94 | # Each layer creates two Trace metrics: 95 | # - a detailed one distinguished by type/name/scope/desc 96 | # - a summary one distinguished only by type 97 | # 98 | # TODO: 99 | # Layers inside of Layers isn't scoped fully here. The recursive call 100 | # should figure out if we need to update the scope we're passing down the 101 | # tree. 102 | # 103 | # In ruby land, that would be a situation like: 104 | # Controller 105 | # DB <-- scoped under controller 106 | # View 107 | # DB <-- scoped under View 108 | defp create_trace_metrics(layer, scope_stack, %MetricSet{} = metric_set) do 109 | detail_metric = Metric.from_layer(layer, ScopeStack.current_scope(scope_stack)) 110 | summary_metric = Metric.from_layer_as_summary(layer) 111 | 112 | new_scope_stack = ScopeStack.push_scope(scope_stack, layer) 113 | 114 | # Absorb each child recursively 115 | Enum.reduce(layer.children, metric_set, fn child, set -> 116 | create_trace_metrics(child, new_scope_stack, set) 117 | end) 118 | # Then absorb this layer's 2 metrics 119 | |> MetricSet.absorb(detail_metric) 120 | |> MetricSet.absorb(summary_metric) 121 | end 122 | 123 | ##################### 124 | # Scoring a trace # 125 | ##################### 126 | 127 | @point_multiplier_speed 0.25 128 | @point_multiplier_percentile 1.0 129 | 130 | defp key(%__MODULE__{} = trace) do 131 | trace.type <> "/" <> trace.name 132 | end 133 | 134 | def as_scored_item(%__MODULE__{} = trace) do 135 | {{:score, score(trace), key(trace)}, trace} 136 | end 137 | 138 | def score(%__MODULE__{} = trace) do 139 | duration_score(trace) + percentile_score(trace) 140 | end 141 | 142 | defp duration_score(%__MODULE__{} = trace) do 143 | :math.log(1 + Duration.as(trace.total_call_time, :seconds)) * @point_multiplier_speed 144 | end 145 | 146 | defp percentile_score(%__MODULE__{} = trace) do 147 | with {:ok, percentile} <- 148 | ScoutApm.PersistentHistogram.percentile_for_value( 149 | key(trace), 150 | Duration.as(trace.total_call_time, :seconds) 151 | ) do 152 | raw = 153 | cond do 154 | # Don't put much emphasis on capturing low percentiles. 155 | percentile < 40 -> 156 | 0.4 157 | 158 | # Higher here to get more "normal" mean traces 159 | percentile < 60 -> 160 | 1.4 161 | 162 | # Between 60 & 90% is fine. 163 | percentile < 90 -> 164 | 0.7 165 | 166 | # Highest here to get 90+%ile traces 167 | percentile >= 90 -> 168 | 1.8 169 | end 170 | 171 | raw * @point_multiplier_percentile 172 | else 173 | # If we failed to lookup the percentile, just give back a 0 score. 174 | err -> 175 | ScoutApm.Logger.log(:debug, "Failed to get percentile_score, error: #{err}") 176 | 0 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /test/scout_apm/plugs/controller_timer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Plugs.ControllerTimerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | alias ScoutApm.Plugs.ControllerTimer 5 | 6 | setup do 7 | ScoutApm.TestCollector.clear_messages() 8 | :ok 9 | end 10 | 11 | test "creates web trace" do 12 | conn(:get, "/") 13 | |> ScoutApm.TestPlugApp.call([]) 14 | 15 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 16 | 17 | assert Enum.any?(commands, fn command -> 18 | map = Map.get(command, :StartSpan) 19 | map && Map.get(map, :operation) == "Controller/PageController#index" 20 | end) 21 | end 22 | 23 | test "includes error metric on 500 response" do 24 | conn(:get, "/500") 25 | |> ScoutApm.TestPlugApp.call([]) 26 | 27 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 28 | 29 | assert Enum.any?(commands, fn command -> 30 | map = Map.get(command, :StartSpan) 31 | map && Map.get(map, :operation) == "Controller/PageController#500" 32 | end) 33 | 34 | assert Enum.any?(commands, fn command -> 35 | map = Map.get(command, :TagRequest) 36 | map && Map.get(map, :tag) == "error" && Map.get(map, :value) == "true" 37 | end) 38 | end 39 | 40 | test "adds ip context" do 41 | conn(:get, "/") 42 | |> ScoutApm.TestPlugApp.call([]) 43 | 44 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 45 | 46 | assert Enum.any?(commands, fn command -> 47 | map = Map.get(command, :StartSpan) 48 | map && Map.get(map, :operation) == "Controller/PageController#index" 49 | end) 50 | 51 | assert Enum.any?(commands, fn command -> 52 | map = Map.get(command, :TagRequest) 53 | map && Map.get(map, :tag) == :ip && is_binary(Map.get(map, :value)) 54 | end) 55 | end 56 | 57 | test "adds path context" do 58 | conn(:get, "/") 59 | |> ScoutApm.TestPlugApp.call([]) 60 | 61 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 62 | 63 | assert Enum.any?(commands, fn command -> 64 | map = Map.get(command, :StartSpan) 65 | map && Map.get(map, :operation) == "Controller/PageController#index" 66 | end) 67 | 68 | assert Enum.any?(commands, fn command -> 69 | map = Map.get(command, :TagRequest) 70 | map && Map.get(map, :tag) == "path" && Map.get(map, :value) == "/" 71 | end) 72 | end 73 | 74 | test "adds ip context from x-forwarded-for header" do 75 | conn(:get, "/x-forwarded-for") 76 | |> ScoutApm.TestPlugApp.call([]) 77 | 78 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 79 | 80 | assert Enum.any?(commands, fn command -> 81 | map = Map.get(command, :StartSpan) 82 | map && Map.get(map, :operation) == "Controller/PageController#x-forwarded-for" 83 | end) 84 | 85 | assert Enum.any?(commands, fn command -> 86 | map = Map.get(command, :TagRequest) 87 | map && Map.get(map, :tag) == :ip && Map.get(map, :value) == "1.2.3.4" 88 | end) 89 | end 90 | 91 | test "does not create web trace when calling ScoutApm.TrackedRequest.ignore/0" do 92 | conn(:get, "/?ignore=true") 93 | |> ScoutApm.TestPlugApp.call([]) 94 | 95 | assert ScoutApm.TestCollector.messages() == [] 96 | end 97 | 98 | test "adds queue time context from headers" do 99 | # Set queue time to ~10 milliseconds before request returns 100 | queue_start = 101 | DateTime.utc_now() 102 | |> DateTime.to_unix(:millisecond) 103 | |> Kernel.-(10) 104 | 105 | conn(:get, "/x-forwarded-for") 106 | |> Plug.Conn.put_req_header("x-request-start", "t=#{queue_start}") 107 | |> ScoutApm.TestPlugApp.call([]) 108 | 109 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 110 | 111 | %{ 112 | TagRequest: %{ 113 | value: queue_time 114 | } 115 | } = 116 | Enum.find(commands, fn command -> 117 | map = Map.get(command, :TagRequest) 118 | 119 | map && Map.get(map, :tag) == "scout.queue_time_ns" 120 | end) 121 | 122 | # queue_time should be about 10 million nanoseconds 123 | # (between 10ms and 100ms) 124 | queue_time = String.to_integer(queue_time) 125 | assert queue_time >= 10_000_000 126 | assert queue_time < 100_000_000 127 | end 128 | 129 | test "adds queue time context from headers in nginx format" do 130 | # Set queue time to ~10 milliseconds before request returns 131 | queue_start = 132 | DateTime.utc_now() 133 | |> DateTime.to_unix(:millisecond) 134 | |> Kernel.-(10) 135 | 136 | conn(:get, "/x-forwarded-for") 137 | |> Plug.Conn.put_req_header("x-queue-start", "#{queue_start}") 138 | |> ScoutApm.TestPlugApp.call([]) 139 | 140 | [%{BatchCommand: %{commands: commands}}] = ScoutApm.TestCollector.messages() 141 | 142 | %{ 143 | TagRequest: %{ 144 | value: queue_time 145 | } 146 | } = 147 | Enum.find(commands, fn command -> 148 | map = Map.get(command, :TagRequest) 149 | 150 | map && Map.get(map, :tag) == "scout.queue_time_ns" 151 | end) 152 | 153 | # queue_time should be about 10 million nanoseconds 154 | # (between 10ms and 100ms) 155 | queue_time = String.to_integer(queue_time) 156 | assert queue_time >= 10_000_000 157 | assert queue_time < 100_000_000 158 | end 159 | 160 | describe "action_name/1" do 161 | setup do 162 | conn = conn(:get, "/") |> ScoutApm.TestPlugApp.call([]) 163 | 164 | [conn: conn] 165 | end 166 | 167 | test "configured to trim application name (default)", %{conn: conn} do 168 | assert ControllerTimer.action_name(conn, []) == "PageController#index" 169 | end 170 | 171 | test "configured to include application name", %{conn: conn} do 172 | assert ControllerTimer.action_name(conn, include_application_name: true) == 173 | "MyTestApp.PageController#index" 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/scout_apm/core/agent_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Core.AgentManager do 2 | use GenServer 3 | alias ScoutApm.Core 4 | alias ScoutApm.Core.Manifest 5 | @behaviour ScoutApm.Collector 6 | 7 | defstruct [:socket] 8 | 9 | @type t :: %__MODULE__{ 10 | socket: :gen_tcp.socket() | nil 11 | } 12 | 13 | def start_link(_) do 14 | options = [] 15 | GenServer.start_link(__MODULE__, options, name: __MODULE__) 16 | end 17 | 18 | @impl GenServer 19 | @spec init(any) :: {:ok, t()} 20 | def init(_) do 21 | start_setup() 22 | {:ok, %__MODULE__{socket: nil}} 23 | end 24 | 25 | def start_setup do 26 | GenServer.cast(__MODULE__, :setup) 27 | end 28 | 29 | @spec setup :: :gen_tcp.socket() | nil 30 | def setup do 31 | enabled = ScoutApm.Config.find(:monitor) 32 | core_agent_launch = ScoutApm.Config.find(:core_agent_launch) 33 | key = ScoutApm.Config.find(:key) 34 | 35 | if enabled && core_agent_launch && key do 36 | with {:ok, manifest} <- verify_or_download(), 37 | bin_path when is_binary(bin_path) <- Manifest.bin_path(manifest), 38 | {:ok, socket} <- run(bin_path) do 39 | register() 40 | app_metadata() 41 | socket 42 | else 43 | _e -> 44 | nil 45 | end 46 | else 47 | nil 48 | end 49 | end 50 | 51 | @spec maybe_download :: {:ok, map()} | {:error, any()} 52 | def maybe_download do 53 | if ScoutApm.Config.find(:core_agent_download) do 54 | ScoutApm.Logger.log(:info, "Failed to find valid ScoutApm Core Agent. Attempting download.") 55 | 56 | full_name = Core.agent_full_name() 57 | url = Core.download_url() 58 | dir = ScoutApm.Config.find(:core_agent_dir) 59 | 60 | with :ok <- download_binary(url, dir, "#{full_name}.tgz"), 61 | {:ok, manifest} <- Core.verify(dir) do 62 | ScoutApm.Logger.log(:debug, "Successfully downloaded and verified ScoutApm Core Agent") 63 | {:ok, manifest} 64 | else 65 | _ -> 66 | ScoutApm.Logger.log(:warn, "Failed to start ScoutApm Core Agent") 67 | {:error, :failed_to_start} 68 | end 69 | else 70 | ScoutApm.Logger.log( 71 | :warn, 72 | "Not attempting to download ScoutApm Core Agent due to :core_agent_download configuration" 73 | ) 74 | 75 | {:error, :no_file_download_disabled} 76 | end 77 | end 78 | 79 | @spec download_binary(String.t(), String.t(), String.t()) :: :ok | {:error, any()} 80 | def download_binary(url, directory, file_name) do 81 | destination = Path.join([directory, file_name]) 82 | 83 | with :ok <- File.mkdir_p(directory), 84 | {:ok, 200, _headers, client_ref} <- :hackney.get(url, [], "", follow_redirect: true), 85 | {:ok, body} <- :hackney.body(client_ref), 86 | :ok <- File.write(destination, body), 87 | :ok <- :erl_tar.extract(destination, [:compressed, {:cwd, directory}]) do 88 | ScoutApm.Logger.log(:info, "Downloaded and extracted ScoutApm Core Agent") 89 | :ok 90 | else 91 | e -> 92 | ScoutApm.Logger.log( 93 | :warn, 94 | "Failed to download and extract ScoutApm Core Agent: #{inspect(e)}" 95 | ) 96 | 97 | {:error, :failed_to_download_and_extract} 98 | end 99 | end 100 | 101 | @impl ScoutApm.Collector 102 | def send(message) when is_map(message) do 103 | GenServer.cast(__MODULE__, {:send, message}) 104 | end 105 | 106 | def app_metadata do 107 | message = 108 | ScoutApm.Command.ApplicationEvent.app_metadata() 109 | |> ScoutApm.Command.message() 110 | 111 | send(message) 112 | end 113 | 114 | def register do 115 | name = ScoutApm.Config.find(:name) 116 | key = ScoutApm.Config.find(:key) 117 | hostname = ScoutApm.Config.find(:hostname) 118 | 119 | message = 120 | ScoutApm.Command.message(%ScoutApm.Command.Register{app: name, key: key, host: hostname}) 121 | 122 | send(message) 123 | end 124 | 125 | @impl GenServer 126 | @spec handle_cast(any, t()) :: {:noreply, t()} 127 | def handle_cast(:setup, state) do 128 | {:noreply, %{state | socket: setup()}} 129 | end 130 | 131 | @impl GenServer 132 | def handle_cast({:send, _message}, %{socket: nil} = state) do 133 | ScoutApm.Logger.log( 134 | :warn, 135 | "ScoutApm Core Agent is not connected. Skipping sending event." 136 | ) 137 | 138 | {:noreply, state} 139 | end 140 | 141 | @impl GenServer 142 | def handle_cast({:send, message}, state) when is_map(message) do 143 | state = send_message(message, state) 144 | {:noreply, state} 145 | end 146 | 147 | @impl GenServer 148 | @spec handle_call(any, any(), t()) :: {:reply, any, t()} 149 | def handle_call({:send, _message}, _from, %{socket: nil} = state) do 150 | ScoutApm.Logger.log( 151 | :warn, 152 | "ScoutApm Core Agent is not connected. Skipping sending event." 153 | ) 154 | 155 | {:reply, state, state} 156 | end 157 | 158 | @impl GenServer 159 | def handle_call({:send, message}, _from, state) when is_map(message) do 160 | state = send_message(message, state) 161 | {:reply, state, state} 162 | end 163 | 164 | @impl GenServer 165 | @spec handle_info(any, t()) :: {:noreply, t()} 166 | def handle_info(_m, state) do 167 | {:noreply, state} 168 | end 169 | 170 | @spec pad_leading(binary(), integer(), integer()) :: binary() 171 | def pad_leading(binary, len, byte \\ 0) 172 | 173 | def pad_leading(binary, len, byte) 174 | when is_binary(binary) and is_integer(len) and is_integer(byte) and len > 0 and 175 | byte_size(binary) >= len, 176 | do: binary 177 | 178 | def pad_leading(binary, len, byte) 179 | when is_binary(binary) and is_integer(len) and is_integer(byte) and len > 0 do 180 | (<> |> :binary.copy(len - byte_size(binary))) <> binary 181 | end 182 | 183 | @spec run(String.t()) :: {:ok, :gen_tcp.socket()} | nil 184 | def run(bin_path) do 185 | ip = 186 | ScoutApm.Config.find(:core_agent_tcp_ip) 187 | |> :inet_parse.ntoa() 188 | 189 | port = ScoutApm.Config.find(:core_agent_tcp_port) 190 | socket_path = Core.socket_path() 191 | 192 | args = ["start", "--socket", socket_path, "--daemonize", "true", "--tcp", "#{ip}:#{port}"] 193 | 194 | with {_, 0} <- System.cmd(bin_path, args), 195 | {:ok, socket} <- try_connect_twice(ip, port) do 196 | {:ok, socket} 197 | else 198 | e -> 199 | ScoutApm.Logger.log( 200 | :warn, 201 | "Unable to start and connect to ScoutApm Core Agent: #{inspect(e)}" 202 | ) 203 | 204 | nil 205 | end 206 | end 207 | 208 | defp send_message(message, %{socket: socket} = state) do 209 | with {:ok, encoded} <- Jason.encode(message), 210 | message_length <- byte_size(encoded), 211 | binary_length <- pad_leading(:binary.encode_unsigned(message_length, :big), 4, 0), 212 | :ok <- :gen_tcp.send(socket, binary_length), 213 | :ok <- :gen_tcp.send(socket, encoded), 214 | {:ok, <>} <- :gen_tcp.recv(socket, 4), 215 | {:ok, msg} <- :gen_tcp.recv(socket, message_length), 216 | {:ok, decoded_msg} <- Jason.decode(msg) do 217 | ScoutApm.Logger.log( 218 | :debug, 219 | "Received message of length #{message_length}: #{inspect(decoded_msg)}" 220 | ) 221 | 222 | state 223 | else 224 | {:error, :closed} -> 225 | Port.close(socket) 226 | 227 | ScoutApm.Logger.log( 228 | :warn, 229 | "ScoutApm Core Agent TCP socket closed. Attempting to reconnect." 230 | ) 231 | 232 | %{state | socket: setup()} 233 | 234 | {:error, :enotconn} -> 235 | Port.close(socket) 236 | 237 | ScoutApm.Logger.log( 238 | :warn, 239 | "ScoutApm Core Agent TCP socket disconnected. Attempting to reconnect." 240 | ) 241 | 242 | %{state | socket: setup()} 243 | 244 | e -> 245 | Port.close(socket) 246 | 247 | ScoutApm.Logger.log( 248 | :warn, 249 | "Error in ScoutApm Core Agent TCP socket: #{inspect(e)}. Attempting to reconnect." 250 | ) 251 | 252 | %{state | socket: setup()} 253 | end 254 | end 255 | 256 | @spec verify_or_download :: {:ok, map()} | {:error, any()} 257 | def verify_or_download do 258 | dir = ScoutApm.Config.find(:core_agent_dir) 259 | 260 | case Core.verify(dir) do 261 | {:ok, manifest} -> 262 | ScoutApm.Logger.log(:info, "Found valid Scout Core Agent") 263 | {:ok, manifest} 264 | 265 | {:error, _reason} -> 266 | maybe_download() 267 | end 268 | end 269 | 270 | @spec try_connect_twice(charlist(), char()) :: 271 | {:ok, :gen_tcp.socket()} | {:error, atom()} 272 | defp try_connect_twice(ip, port) do 273 | case :gen_tcp.connect(ip, port, [{:active, false}, :binary]) do 274 | {:ok, socket} -> 275 | {:ok, socket} 276 | 277 | _ -> 278 | :timer.sleep(500) 279 | :gen_tcp.connect(ip, port, [{:active, false}, :binary]) 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "approximate_histogram": {:hex, :approximate_histogram, "0.1.1", "198eb36681e763ed4baab6ca0682acec4ef642f60ba272f251d3059052f4f378", [:mix], [], "hexpm", "6cce003d09656efbfe80b4a50f19e6c1f8eaf1424f08e4a96036b340fc67019d"}, 3 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 4 | "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, 5 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 6 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "f4763bbe08233eceed6f24bc4fcc8d71c17cfeafa6439157c57349aa1bb4f17c"}, 7 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm", "db622da03aa039e6366ab953e31186cc8190d32905e33788a1acb22744e6abd2"}, 8 | "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "539596b6774069260d5938aa73042a2f5157e1c0215aa35f5a53d83889546d14"}, 9 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"}, 10 | "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm", "4bedcec35de03b5f559fd2386be24d08f7637c374d3a85d3fe0911eecdae838a"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.28", "0bf6546eb7cd6185ae086cbc5d20cd6dbb4b428aad14c02c49f7b554484b4586", [:mix], [], "hexpm", "501cef12286a3231dc80c81352a9453decf9586977f917a96e619293132743fb"}, 12 | "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, 13 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 16 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 19 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 20 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 24 | "phoenix": {:hex, :phoenix, "1.6.14", "57678366dc1d5bad49832a0fc7f12c2830c10d3eacfad681bfe9602cd4445f04", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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", "d48c0da00b3d4cd1aad6055387917491af9f6e1f1e96cedf6c6b7998df9dba26"}, 25 | "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, 26 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 27 | "phoenix_slime": {:hex, :phoenix_slime, "0.9.0", "1dbebe18757d57cfd2e62314c04c17d9acc7d31e2ca9387dd3fdeebe52fbd4bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:slime, "~> 0.16", [hex: :slime, repo: "hexpm", optional: false]}], "hexpm", "1028ba679568b6a3d4597865ca4456d0469802927efc73220a5396a42f218b1b"}, 28 | "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"}, 29 | "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"}, 30 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 31 | "proper": {:hex, :proper, "1.1.1-beta", "5eef3fa0cae017ecb4bf6fd6a144349a048e999223a4f1e98ce372ff1772edd3", [:rebar3], []}, 32 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm", "6e56493a862433fccc3aca3025c946d6720d8eedf6e3e6fb911952a7071c357f"}, 33 | "slime": {:hex, :slime, "0.16.0", "4f9c677ca37b2817cd10422ecb42c524fe904d3630acf242b81dfe189900272a", [:mix], [], "hexpm", "9a8c51853302df424aea6ce590e5f67ad8ba9581dd62ecb0c685bcf462c4cd79"}, 34 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 35 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 36 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 37 | } 38 | -------------------------------------------------------------------------------- /lib/scout_apm/tracked_request.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.TrackedRequest do 2 | @moduledoc """ 3 | Stores information about a single request, as the request is happening. 4 | Attempts to do minimal processing. Its job is only to collect up information. 5 | Once the request is finished, the last layer will be stopped - and we can 6 | send this whole data structure off to be processed. 7 | 8 | A quick visual of how this looks: 9 | 10 | START Controller (this is scope.) 11 | TRACK Ecto 12 | 13 | START View 14 | TRACK Ecto 15 | 16 | START Partial View 17 | STOP Partial View 18 | STOP View 19 | STOP Controller 20 | """ 21 | @collector_module ScoutApm.Config.find(:collector_module) 22 | 23 | alias ScoutApm.Internal.Layer 24 | 25 | defstruct [ 26 | :id, 27 | :root_layer, 28 | :layers, 29 | :children, 30 | :contexts, 31 | :collector_fn, 32 | :error, 33 | :ignored, 34 | :ignoring_depth 35 | ] 36 | 37 | ############### 38 | # Interface # 39 | ############### 40 | 41 | def start_layer(%__MODULE__{ignored: true} = tr, _type, _name, _opts) do 42 | %{tr | ignoring_depth: tr.ignoring_depth + 1} 43 | end 44 | 45 | def start_layer(%__MODULE__{} = tr, type, name, opts) do 46 | layer = Layer.new(%{type: type, name: name, opts: opts || []}) 47 | push_layer(tr, layer) 48 | end 49 | 50 | def start_layer(type, name, opts \\ []) do 51 | with_saved_tracked_request(fn tr -> start_layer(tr, type, name, opts) end) 52 | end 53 | 54 | def stop_layer() do 55 | stop_layer(fn x -> x end) 56 | end 57 | 58 | def stop_layer(callback) when is_function(callback) do 59 | with_saved_tracked_request(fn tr -> stop_layer(tr, callback) end) 60 | end 61 | 62 | def stop_layer(%__MODULE__{} = tr) do 63 | stop_layer(tr, fn x -> x end) 64 | end 65 | 66 | def stop_layer(%__MODULE__{ignored: true} = tr, _callback) do 67 | if tr.ignoring_depth == 1 do 68 | # clear tracked request when last layer is stopped 69 | nil 70 | else 71 | %{tr | ignoring_depth: tr.ignoring_depth - 1} 72 | end 73 | end 74 | 75 | def stop_layer(%__MODULE__{layers: []} = tracked_request, callback) 76 | when is_function(callback) do 77 | ScoutApm.Logger.log( 78 | :info, 79 | "Scout Layer mismatch when stopping layer in #{inspect(tracked_request)}" 80 | ) 81 | 82 | :error 83 | end 84 | 85 | def stop_layer(%__MODULE__{children: []} = tracked_request, callback) 86 | when is_function(callback) do 87 | ScoutApm.Logger.log( 88 | :info, 89 | "Scout Layer mismatch when stopping layer in #{inspect(tracked_request)}" 90 | ) 91 | 92 | :error 93 | end 94 | 95 | def stop_layer(%__MODULE__{} = tr, callback) when is_function(callback) do 96 | {popped_layer, tr1} = pop_layer(tr) 97 | 98 | updated_layer = 99 | popped_layer 100 | |> Layer.update_stopped_at() 101 | |> callback.() 102 | 103 | tr2 = 104 | tr1 105 | |> record_child_of_current_layer(updated_layer) 106 | 107 | # We finished tracing this request, so go and record it. 108 | if Enum.count(layers(tr2)) == 0 do 109 | request = tr2 |> with_root_layer(updated_layer) 110 | request.collector_fn.(request) 111 | nil 112 | else 113 | tr2 114 | end 115 | end 116 | 117 | def track_layer(%__MODULE__{} = tr, type, name, duration, fields, callback) do 118 | layer = 119 | Layer.new(%{type: type, name: name, opts: []}) 120 | |> Layer.update_stopped_at() 121 | |> Layer.set_manual_duration(duration) 122 | |> Layer.update_fields(fields) 123 | |> callback.() 124 | 125 | record_child_of_current_layer(tr, layer) 126 | end 127 | 128 | def track_layer(type, name, duration, fields, callback \\ fn x -> x end) do 129 | with_saved_tracked_request(fn tr -> 130 | track_layer(tr, type, name, duration, fields, callback) 131 | end) 132 | end 133 | 134 | @doc """ 135 | Marks the current tracked request as ignored, preventing it from being sent or included 136 | in any metrics. It can be used in both web requests and jobs. 137 | 138 | If you'd like to sample only 75% of your application's web requests, a Plug is a good 139 | way to do that: 140 | 141 | defmodule MyApp.ScoutSamplingPlug do 142 | @behaviour Plug 143 | def init(_), do: [] 144 | 145 | def call(conn, _opts) do 146 | # capture 75% of requests 147 | if :rand.uniform() > 0.75 do 148 | ScoutApm.TrackedRequest.ignore() 149 | end 150 | end 151 | end 152 | 153 | Instrumented jobs can also be ignored by conditionally calling this function: 154 | 155 | deftransaction multiplication_job(num1, num2) do 156 | if num1 < 0 do 157 | ScoutApm.TrackedRequest.ignore() 158 | end 159 | 160 | num1 * num2 161 | end 162 | """ 163 | def ignore() do 164 | with_saved_tracked_request(fn tr -> 165 | %{tr | ignored: true, ignoring_depth: Enum.count(tr.layers), layers: [], root_layer: nil} 166 | end) 167 | end 168 | 169 | def rename(new_transaction_name) when is_binary(new_transaction_name) do 170 | ScoutApm.Context.add("transaction.name", new_transaction_name) 171 | end 172 | 173 | def record_context(%__MODULE__{} = tr, %ScoutApm.Internal.Context{} = context), 174 | do: %{tr | contexts: [context | tr.contexts]} 175 | 176 | def record_context(%ScoutApm.Internal.Context{} = context), 177 | do: with_saved_tracked_request(fn tr -> record_context(tr, context) end) 178 | 179 | @doc """ 180 | Not intended for public use. Applies a function that takes an Layer, and 181 | returns a Layer to the currently tracked layer. Building block for things 182 | like: "update_desc" 183 | """ 184 | def update_current_layer(%__MODULE__{} = tr, fun) when is_function(fun) do 185 | [current | rest] = layers(tr) 186 | new = fun.(current) 187 | Map.put(tr, :layers, [new | rest]) 188 | end 189 | 190 | def update_current_layer(fun) when is_function(fun) do 191 | with_saved_tracked_request(fn tr -> update_current_layer(tr, fun) end) 192 | end 193 | 194 | ################################# 195 | # Constructors & Manipulation # 196 | ################################# 197 | 198 | def new(custom_collector \\ nil) do 199 | save(%__MODULE__{ 200 | id: ScoutApm.Utils.random_string(12), 201 | root_layer: nil, 202 | layers: [], 203 | ignored: false, 204 | children: [], 205 | contexts: [], 206 | collector_fn: build_collector_fn(custom_collector) 207 | }) 208 | end 209 | 210 | def mark_error() do 211 | with_saved_tracked_request(fn request -> 212 | mark_error(request) 213 | end) 214 | end 215 | 216 | def mark_error(%__MODULE__{} = request) do 217 | %{request | error: true} 218 | end 219 | 220 | defp build_collector_fn(f) when is_function(f), do: f 221 | defp build_collector_fn({module, fun}), do: fn request -> apply(module, fun, [request]) end 222 | 223 | defp build_collector_fn(_), 224 | do: fn request -> 225 | batch = 226 | ScoutApm.Command.Batch.from_tracked_request(request) 227 | |> ScoutApm.Command.message() 228 | 229 | @collector_module.send(batch) 230 | end 231 | 232 | def change_collector_fn(f), do: lookup() |> change_collector_fn(f) |> save() 233 | 234 | def change_collector_fn(%__MODULE__{} = tr, f) do 235 | %{tr | collector_fn: build_collector_fn(f)} 236 | end 237 | 238 | defp lookup() do 239 | Process.get(:scout_apm_request) || new() 240 | end 241 | 242 | defp save(nil) do 243 | Process.delete(:scout_apm_request) 244 | nil 245 | end 246 | 247 | defp save(:error) do 248 | Process.delete(:scout_apm_request) 249 | nil 250 | end 251 | 252 | defp save(%__MODULE__{} = tr) do 253 | Process.put(:scout_apm_request, tr) 254 | tr 255 | end 256 | 257 | defp with_saved_tracked_request(f) when is_function(f) do 258 | lookup() 259 | |> f.() 260 | |> save() 261 | end 262 | 263 | defp layers(%__MODULE__{} = tr) do 264 | tr 265 | |> Map.get(:layers) 266 | end 267 | 268 | defp with_root_layer(%__MODULE__{} = tr, layer) do 269 | tr 270 | |> Map.update!( 271 | :root_layer, 272 | fn 273 | nil -> layer 274 | rl -> rl 275 | end 276 | ) 277 | end 278 | 279 | defp push_layer(%__MODULE__{} = tr, l) do 280 | tr 281 | 282 | # Track the layer itself 283 | |> Map.update!(:layers, fn ls -> [l | ls] end) 284 | 285 | # Push a new children tracking layer 286 | |> Map.update!(:children, fn cs -> [[] | cs] end) 287 | end 288 | 289 | # Pop this layer off the layer stack 290 | # Pop the children recorded for this layer 291 | # Attach the children to the layer 292 | # - note, we can't save this layer into its parent's children array yet, since it will get further edited in stop_layer 293 | # Return the layer 294 | defp pop_layer(%__MODULE__{} = tr) do 295 | s0 = tr 296 | {cur_layer, s1} = Map.get_and_update(s0, :layers, fn [cur | rest] -> {cur, rest} end) 297 | 298 | {children, new_tr} = Map.get_and_update(s1, :children, fn [cur | rest] -> {cur, rest} end) 299 | 300 | popped_layer = 301 | cur_layer 302 | |> Layer.update_children(Enum.reverse(children)) 303 | 304 | {popped_layer, new_tr} 305 | end 306 | 307 | # Inserts a child layer into the children array for its parent. Should be 308 | # called after pop_layer() has been called, so that the child list at the 309 | # head is for its parent. 310 | defp record_child_of_current_layer(%__MODULE__{} = tr, child) do 311 | tr 312 | |> Map.update!(:children, fn 313 | [layer_children | cs] -> [[child | layer_children] | cs] 314 | [] -> [] 315 | end) 316 | end 317 | end 318 | -------------------------------------------------------------------------------- /lib/scout_apm/commands.ex: -------------------------------------------------------------------------------- 1 | defprotocol ScoutApm.Command do 2 | def message(data) 3 | end 4 | 5 | alias ScoutApm.Command 6 | 7 | defmodule ScoutApm.Command.Register do 8 | defstruct [:app, :key, :host] 9 | end 10 | 11 | defimpl ScoutApm.Command, for: ScoutApm.Command.Register do 12 | def message(%Command.Register{app: app, key: key, host: host}) do 13 | %{ 14 | Register: %{ 15 | app: app, 16 | key: key, 17 | host: host, 18 | language: "elixir", 19 | api_version: "1.0" 20 | } 21 | } 22 | end 23 | end 24 | 25 | defmodule ScoutApm.Command.StartSpan do 26 | @enforce_keys [:timestamp] 27 | defstruct [:timestamp, :request_id, :span_id, :parent, :operation] 28 | end 29 | 30 | defimpl ScoutApm.Command, for: ScoutApm.Command.StartSpan do 31 | def message(%Command.StartSpan{} = span) do 32 | %{ 33 | StartSpan: %{ 34 | timestamp: "#{NaiveDateTime.to_iso8601(span.timestamp)}Z", 35 | request_id: span.request_id, 36 | span_id: span.span_id, 37 | parent_id: span.parent, 38 | operation: span.operation 39 | } 40 | } 41 | end 42 | end 43 | 44 | defmodule ScoutApm.Command.StopSpan do 45 | @enforce_keys [:timestamp] 46 | defstruct [:timestamp, :request_id, :span_id] 47 | end 48 | 49 | defimpl ScoutApm.Command, for: ScoutApm.Command.StopSpan do 50 | def message(%Command.StopSpan{} = span) do 51 | %{ 52 | StopSpan: %{ 53 | timestamp: "#{NaiveDateTime.to_iso8601(span.timestamp)}Z", 54 | request_id: span.request_id, 55 | span_id: span.span_id 56 | } 57 | } 58 | end 59 | end 60 | 61 | defmodule ScoutApm.Command.StartRequest do 62 | @enforce_keys [:timestamp] 63 | defstruct [:timestamp, :request_id] 64 | end 65 | 66 | defimpl ScoutApm.Command, for: ScoutApm.Command.StartRequest do 67 | def message(%Command.StartRequest{} = request) do 68 | %{ 69 | StartRequest: %{ 70 | timestamp: "#{NaiveDateTime.to_iso8601(request.timestamp)}Z", 71 | request_id: request.request_id 72 | } 73 | } 74 | end 75 | end 76 | 77 | defmodule ScoutApm.Command.FinishRequest do 78 | @enforce_keys [:timestamp] 79 | defstruct [:timestamp, :request_id] 80 | end 81 | 82 | defimpl ScoutApm.Command, for: ScoutApm.Command.FinishRequest do 83 | def message(%Command.FinishRequest{} = request) do 84 | %{ 85 | FinishRequest: %{ 86 | timestamp: "#{NaiveDateTime.to_iso8601(request.timestamp)}Z", 87 | request_id: request.request_id 88 | } 89 | } 90 | end 91 | end 92 | 93 | defmodule ScoutApm.Command.TagSpan do 94 | @enforce_keys [:timestamp] 95 | defstruct [:timestamp, :request_id, :span_id, :tag, :value] 96 | end 97 | 98 | defimpl ScoutApm.Command, for: ScoutApm.Command.TagSpan do 99 | def message(%Command.TagSpan{} = span) do 100 | %{ 101 | TagSpan: %{ 102 | timestamp: "#{NaiveDateTime.to_iso8601(span.timestamp)}Z", 103 | request_id: span.request_id, 104 | span_id: span.span_id, 105 | tag: span.tag, 106 | value: span.value 107 | } 108 | } 109 | end 110 | end 111 | 112 | defmodule ScoutApm.Command.TagRequest do 113 | @enforce_keys [:timestamp] 114 | defstruct [:timestamp, :request_id, :tag, :value] 115 | end 116 | 117 | defimpl ScoutApm.Command, for: ScoutApm.Command.TagRequest do 118 | def message(%Command.TagRequest{} = request) do 119 | %{ 120 | TagRequest: %{ 121 | timestamp: "#{NaiveDateTime.to_iso8601(request.timestamp)}Z", 122 | request_id: request.request_id, 123 | tag: request.tag, 124 | value: request.value 125 | } 126 | } 127 | end 128 | end 129 | 130 | defmodule ScoutApm.Command.ApplicationEvent do 131 | @enforce_keys [:timestamp] 132 | defstruct [:timestamp, :event_type, :event_value, :source] 133 | 134 | def app_metadata do 135 | %ScoutApm.Command.ApplicationEvent{ 136 | timestamp: NaiveDateTime.utc_now(), 137 | event_type: "scout.metadata", 138 | event_value: %{ 139 | language: "elixir", 140 | version: System.version(), 141 | server_time: "#{NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())}Z", 142 | framework: "", 143 | framework_version: "", 144 | environment: "", 145 | app_server: "", 146 | hostname: ScoutApm.Cache.hostname(), 147 | database_engine: "", 148 | database_adapter: "", 149 | application_name: ScoutApm.Config.find(:name), 150 | libraries: libraries(), 151 | paas: "", 152 | application_root: "", 153 | git_sha: ScoutApm.Cache.git_sha() 154 | }, 155 | source: inspect(self()) 156 | } 157 | end 158 | 159 | defp libraries do 160 | Enum.map( 161 | Application.loaded_applications(), 162 | fn {name, _desc, version} -> [to_string(name), to_string(version)] end 163 | ) 164 | end 165 | end 166 | 167 | defimpl ScoutApm.Command, for: ScoutApm.Command.ApplicationEvent do 168 | def message(%Command.ApplicationEvent{} = event) do 169 | %{ 170 | ApplicationEvent: %{ 171 | timestamp: "#{NaiveDateTime.to_iso8601(event.timestamp)}Z", 172 | event_type: event.event_type, 173 | event_value: event.event_value, 174 | source: event.source 175 | } 176 | } 177 | end 178 | end 179 | 180 | defmodule ScoutApm.Command.CoreAgentVersion do 181 | defstruct [] 182 | end 183 | 184 | defimpl ScoutApm.Command, for: ScoutApm.Command.CoreAgentVersion do 185 | def message(%Command.CoreAgentVersion{} = _version) do 186 | %{ 187 | CoreAgentVersion: %{} 188 | } 189 | end 190 | end 191 | 192 | defmodule ScoutApm.Command.Batch do 193 | @enforce_keys [:commands] 194 | defstruct [:commands] 195 | alias ScoutApm.Command 196 | alias ScoutApm.Internal.Layer 197 | 198 | def from_tracked_request(request) do 199 | start_request = %Command.StartRequest{ 200 | timestamp: request.root_layer.started_at, 201 | request_id: request.id 202 | } 203 | 204 | commands = [start_request] 205 | 206 | tag_requests = 207 | Enum.map(request.contexts, fn %{key: key, value: value} -> 208 | %Command.TagRequest{ 209 | timestamp: start_request.timestamp, 210 | request_id: request.id, 211 | tag: key, 212 | value: value 213 | } 214 | end) 215 | 216 | tag_requests = 217 | if request.root_layer && request.root_layer.uri do 218 | uri_tag = %Command.TagRequest{ 219 | timestamp: start_request.timestamp, 220 | request_id: request.id, 221 | tag: "path", 222 | value: request.root_layer.uri 223 | } 224 | 225 | [uri_tag | tag_requests] 226 | else 227 | tag_requests 228 | end 229 | 230 | commands = commands ++ tag_requests 231 | 232 | spans = build_layer_spans([request.root_layer], request.id, nil, []) 233 | 234 | spans = 235 | if request.error == true do 236 | spans ++ 237 | [ 238 | %Command.TagRequest{ 239 | timestamp: start_request.timestamp, 240 | request_id: request.id, 241 | tag: "error", 242 | value: "true" 243 | } 244 | ] 245 | else 246 | spans 247 | end 248 | 249 | commands = commands ++ spans 250 | 251 | finish_request = %Command.FinishRequest{ 252 | timestamp: request.root_layer.stopped_at, 253 | request_id: request.id 254 | } 255 | 256 | commands = commands ++ [finish_request] 257 | 258 | %Command.Batch{ 259 | commands: commands 260 | } 261 | end 262 | 263 | defp build_layer_spans(children, request_id, parent_id, spans) do 264 | Enum.reduce(children, spans, fn child, spans -> 265 | [start | rest] = layer_to_spans(child, request_id, parent_id) 266 | build_layer_spans(child.children, request_id, start.span_id, spans ++ [start | rest]) 267 | end) 268 | end 269 | 270 | defp layer_to_spans(layer, request_id, parent_span_id) do 271 | span_id = ScoutApm.Utils.random_string(12) 272 | 273 | start_span = %Command.StartSpan{ 274 | timestamp: layer.started_at, 275 | request_id: request_id, 276 | span_id: span_id, 277 | parent: parent_span_id, 278 | operation: operation(layer) 279 | } 280 | 281 | stop_timestamp = 282 | if layer.manual_duration do 283 | NaiveDateTime.add(layer.started_at, layer.manual_duration.value, :microsecond) 284 | else 285 | layer.stopped_at 286 | end 287 | 288 | stop_span = %Command.StopSpan{ 289 | timestamp: stop_timestamp, 290 | request_id: request_id, 291 | span_id: span_id 292 | } 293 | 294 | tag_spans = tag_spans(layer, request_id, span_id) 295 | 296 | [start_span, stop_span] ++ tag_spans 297 | end 298 | 299 | defp operation(%Layer{type: "Controller"} = layer), do: "#{layer.type}/#{layer.name}" 300 | defp operation(%Layer{type: "Job"} = layer), do: "#{layer.type}/#{layer.name}" 301 | defp operation(%Layer{type: "Ecto"}), do: "SQL/Query" 302 | defp operation(%Layer{type: "EEx"}), do: "Template/Render" 303 | defp operation(%Layer{type: "Exs"}), do: "Template/Render" 304 | defp operation(layer), do: "#{layer.type}/#{layer.name}" 305 | 306 | defp tag_spans(%Layer{type: "Ecto"} = layer, request_id, span_id) do 307 | [ 308 | %Command.TagSpan{ 309 | timestamp: layer.started_at, 310 | request_id: request_id, 311 | span_id: span_id, 312 | tag: "db.statement", 313 | value: layer.desc 314 | } 315 | ] 316 | end 317 | 318 | defp tag_spans(%Layer{type: type} = layer, request_id, span_id) when type in ["EEx", "Exs"] do 319 | [ 320 | %Command.TagSpan{ 321 | timestamp: layer.started_at, 322 | request_id: request_id, 323 | span_id: span_id, 324 | tag: "scout.desc", 325 | value: layer.name 326 | } 327 | ] 328 | end 329 | 330 | defp tag_spans(_layer, _request_id, _span_id), do: [] 331 | end 332 | 333 | defimpl ScoutApm.Command, for: ScoutApm.Command.Batch do 334 | def message(%Command.Batch{commands: commands}) do 335 | %{ 336 | BatchCommand: %{ 337 | commands: Enum.map(commands, &ScoutApm.Command.message(&1)) 338 | } 339 | } 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /lib/scout_apm/tracing.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoutApm.Tracing do 2 | @moduledoc """ 3 | Ths module contains functions to create transactions and time the execution of code. It's used to add 4 | instrumentation to an Elixir app. 5 | 6 | Scout's instrumentation is divided into 2 areas: 7 | 8 | 1. __Transactions__: these wrap around a flow of work, like a web request or a GenServer call. The UI groups data under 9 | transactions. 10 | 2. __Timing__: these measure individual pieces of work, like an HTTP request to an outside service or an Ecto query. 11 | 12 | ### Transaction types 13 | 14 | A transaction may be one of two types: 15 | 16 | 1. __web__: a transaction that impacts the main app experience, like a Phoenix controller action. 17 | 2. __background__: a transaction that isn't in the main app flow, like a GenServer call or Exq background job. 18 | 19 | If you are instrumenting a stand-alone Elixir app, treat all transactions as `web`. Data from these transactions 20 | appear in the App overview charts. 21 | 22 | ### deftransaction Macro Example 23 | 24 | Replace your function `def` with `deftransaction` to instrument it. 25 | You can override the name and type by setting the `@transaction_opts` attribute right before the function. 26 | 27 | defmodule CampWaitlist.Web.HtmlChannel do 28 | use Phoenix.Channel 29 | import ScoutApm.Tracing 30 | 31 | # Will appear under "Web" in the UI, named "CampWaitlist.Web.HtmlChannel.join". 32 | @transaction_opts [type: "web"] 33 | deftransaction join("topic:html", _message, socket) do 34 | {:ok, socket} 35 | end 36 | 37 | ## Timing Code 38 | 39 | ### deftiming Macro Example 40 | 41 | defmodule Searcher do 42 | import ScoutApm.Tracing 43 | 44 | # Time associated with this function will appear under "Hound" in timeseries charts. 45 | # The function will appear as `Hound/open_search` in transaction traces. 46 | @timing_opts [category: "Hound"] 47 | deftiming open_search(url) do 48 | navigate_to(url) 49 | end 50 | 51 | # Time associated with this function will appear under "Hound" in timeseries charts. 52 | # The function will appear as `Hound/search` in transaction traces. 53 | @timing_opts [name: "search", category: "Hound"] 54 | deftiming open_search(url) do 55 | navigate_to(url) 56 | end 57 | 58 | ### Category limitations 59 | 60 | We limit the arity of `category`. These are displayed in charts 61 | throughput the UI. These should not be generated dynamically and should be limited 62 | to higher-level categories (ie Postgres, Redis, HTTP, etc). 63 | 64 | ## use vs. import 65 | 66 | To utilize the `deftransaction` and `deftiming` macros, import this module: 67 | 68 | defmodule YourModule 69 | import ScoutApm.Tracing 70 | 71 | To utilize the module attributes (`@transaction` and `@timing`), inject this module via the `use` macro: 72 | 73 | defmodule YourModule 74 | use ScoutApm.Tracing 75 | 76 | You can then call `transaction/4` and `timing/4` via the following qualified module name, ie: 77 | 78 | ScoutApm.Tracing.timing("HTTP", "GitHub", do: ...) 79 | 80 | To drop the full module name, you'll need to use the `import` macro. The following is valid: 81 | 82 | defmodule YourModule 83 | use ScoutApm.Tracing 84 | import ScoutApm.Tracing 85 | 86 | 87 | If you are importing across multiple libraries, it is possible to run into naming collisions. Elixir 88 | has documentation around those issues [here](https://elixir-lang.org/getting-started/alias-require-and-import.html). 89 | """ 90 | 91 | alias ScoutApm.Internal.Layer 92 | alias ScoutApm.Internal.Duration 93 | alias ScoutApm.TrackedRequest 94 | 95 | @doc false 96 | defmacro transaction(type, name, opts \\ [], do: block) do 97 | quote do 98 | TrackedRequest.start_layer( 99 | ScoutApm.Tracing.internal_layer_type(unquote(type)), 100 | unquote(name), 101 | unquote(opts) 102 | ) 103 | 104 | # ensure we record the transaction if it throws an error 105 | try do 106 | (fn -> unquote(block) end).() 107 | rescue 108 | e in RuntimeError -> 109 | # TODO - Add real error tracking 110 | raise e 111 | after 112 | TrackedRequest.stop_layer() 113 | end 114 | end 115 | end 116 | 117 | @doc """ 118 | Creates a transaction defaulting to type `background` with the default name being the fully qualified module, function and arity. 119 | 120 | You can override the name and type by setting the `@transaction_opts` attribute right before the function. 121 | 122 | ## Example Usage 123 | 124 | import ScoutApm.Tracking 125 | 126 | # @transaction_opts [type: "web", name: "name_override"] 127 | deftransaction do_async_work() do 128 | # Do work... 129 | end 130 | """ 131 | defmacro deftransaction(head, body) do 132 | function_head = Macro.to_string(head) 133 | 134 | quote do 135 | options = Module.delete_attribute(__MODULE__, :transaction_opts) || [] 136 | 137 | module = 138 | __MODULE__ 139 | |> Atom.to_string() 140 | |> String.trim_leading("Elixir.") 141 | 142 | name = Keyword.get(options, :name, "#{module}.#{unquote(function_head)}") 143 | type = Keyword.get(options, :type, "background") 144 | Module.put_attribute(__MODULE__, :scout_name, name) 145 | Module.put_attribute(__MODULE__, :scout_type, type) 146 | 147 | def unquote(head) do 148 | transaction(@scout_type, @scout_name) do 149 | unquote(body[:do]) 150 | end 151 | end 152 | 153 | Module.delete_attribute(__MODULE__, :scout_name) 154 | Module.delete_attribute(__MODULE__, :scout_type) 155 | end 156 | end 157 | 158 | @doc false 159 | defmacro timing(category, name, opts \\ [], do: block) do 160 | quote do 161 | TrackedRequest.start_layer(unquote(category), unquote(name), unquote(opts)) 162 | # ensure we record the metric if the timed block throws an error 163 | try do 164 | (fn -> unquote(block) end).() 165 | after 166 | TrackedRequest.stop_layer() 167 | end 168 | end 169 | end 170 | 171 | @doc """ 172 | Times the execution of the given function, labeling it with a `category` and `name` within Scout. The default category is "Custom", and the default name is the fully qualified module, function and arity. 173 | 174 | You can override the category and name by setting the `@timing_opts` attribute right before the function. 175 | 176 | Within a trace in the Scout UI, the block will appear as `category/name` ie `Images/format_avatar` in traces and 177 | will be displayed in timeseries charts under the associated `category`. 178 | 179 | ## Example Usage 180 | 181 | defmodule PhoenixApp.PageController do 182 | use PhoenixApp.Web, :controller 183 | import ScoutApm.Tracing 184 | 185 | # @timing_opts [category: "Images", name: "format_images"] 186 | deftiming format_avatars(params) do 187 | # Formatting avatars 188 | end 189 | 190 | def index(conn, params) do 191 | format_avatars(params) 192 | render conn, "index.html" 193 | end 194 | """ 195 | defmacro deftiming(head, body) do 196 | function_head = Macro.to_string(head) 197 | 198 | quote do 199 | options = Module.delete_attribute(__MODULE__, :timing_opts) || [] 200 | 201 | module = 202 | __MODULE__ 203 | |> Atom.to_string() 204 | |> String.trim_leading("Elixir.") 205 | 206 | name = Keyword.get(options, :name, "#{module}.#{unquote(function_head)}") 207 | category = Keyword.get(options, :category, "Custom") 208 | Module.put_attribute(__MODULE__, :scout_name, name) 209 | Module.put_attribute(__MODULE__, :scout_category, category) 210 | 211 | def unquote(head) do 212 | timing(@scout_category, @scout_name) do 213 | unquote(body[:do]) 214 | end 215 | end 216 | 217 | Module.delete_attribute(__MODULE__, :scout_name) 218 | Module.delete_attribute(__MODULE__, :scout_type) 219 | end 220 | end 221 | 222 | # Converts the public-facing type ("web" or "background") to their internal layer representation. 223 | def internal_layer_type(type) when is_atom(type) do 224 | Atom.to_string(type) |> internal_layer_type 225 | end 226 | 227 | def internal_layer_type(type) when is_binary(type) do 228 | # some coercion to handle capitalization 229 | downcased = String.downcase(type) 230 | 231 | case downcased do 232 | "web" -> 233 | "Controller" 234 | 235 | "background" -> 236 | "Job" 237 | end 238 | end 239 | 240 | @doc """ 241 | Updates the description for the code executing within a call to `timing/4`. The description is displayed 242 | within a Scout trace in the UI. 243 | 244 | This is useful for logging actual HTTP request URLs, SQL queries, etc. 245 | 246 | ## Example Usage 247 | 248 | timing("HTTP", "httparrot") do 249 | update_desc("GET: http://httparrot.herokuapp.com/get") 250 | HTTPoison.get! "http://httparrot.herokuapp.com/get" 251 | end 252 | """ 253 | @spec update_desc(String.t()) :: any 254 | def update_desc(desc) do 255 | TrackedRequest.update_current_layer(fn layer -> 256 | Layer.update_desc(layer, desc) 257 | end) 258 | end 259 | 260 | @doc """ 261 | Adds an timing entry of duration `value` with `units`, labeling it with `category` and `name` within Scout. 262 | 263 | ## Units 264 | 265 | Can be be one of `:microseconds | :milliseconds | :seconds`. These come from `t:ScoutApm.Internal.Duration.unit/0`. 266 | 267 | ## Example Usage 268 | 269 | track("Images", "resize", 200, :milliseconds) 270 | track("HTTP", "get", 300, :milliseconds, desc: "HTTP GET http://api.github.com/") 271 | 272 | ## Opts 273 | 274 | A `desc` may be provided to add a detailed background of the event. These are viewable when accessing a trace in the UI. 275 | 276 | ## The duration must have actually occured 277 | 278 | This function expects that the `ScoutApm.Internal.Duration` generated by `value` and `units` actually occurs in 279 | the transaction. The total time of the transaction IS NOT adjusted. 280 | 281 | This naturally occurs when taking the output of Ecto log entries. 282 | """ 283 | @spec track(String.t(), String.t(), number(), Duration.unit(), keyword()) :: :ok | :error 284 | def track(category, name, value, units, opts \\ []) when is_number(value) do 285 | if value < 0 do 286 | :error 287 | else 288 | duration = Duration.new(value, units) 289 | TrackedRequest.track_layer(category, name, duration, opts) 290 | :ok 291 | end 292 | end 293 | end 294 | --------------------------------------------------------------------------------