├── 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 | 
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 | 
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, " 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 |
--------------------------------------------------------------------------------