From bfb76407d3d646086c1ae82752de0e535446d022 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Wed, 24 Apr 2024 19:16:23 +0200 Subject: [PATCH 01/10] Replace HTTPoison with Tesla, create Segment.Config --- .tool-versions | 2 + config/config.exs | 25 +- lib/segment.ex | 146 ++++---- lib/segment/analytics.ex | 78 ++-- lib/segment/analytics/http.ex | 78 +++- lib/segment/analytics/response_formatter.ex | 136 ------- lib/segment/config.ex | 39 ++ lib/segment/encoder.ex | 41 +-- mix.exs | 6 +- mix.lock | 17 +- test/segment/analytics/http_test.exs | 70 ---- test/segment/analytics/model_test.exs | 4 +- .../analytics/response_formatter_test.exs | 68 ---- test/segment/analytics_test.exs | 335 +++++++++--------- test/segment/encoder_test.exs | 33 +- test/segment_test.exs | 44 --- test/support/factory.ex | 7 + 17 files changed, 436 insertions(+), 693 deletions(-) create mode 100644 .tool-versions delete mode 100644 lib/segment/analytics/response_formatter.ex create mode 100644 lib/segment/config.ex delete mode 100644 test/segment/analytics/http_test.exs delete mode 100644 test/segment/analytics/response_formatter_test.exs delete mode 100644 test/segment_test.exs diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..000a611 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.16.0-otp-26 +erlang 26.2.1 diff --git a/config/config.exs b/config/config.exs index 6dfa82f..e714c9e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,24 +1,3 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. -use Mix.Config +import Config -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for third- -# party users, it should be done in your mix.exs file. - -# Sample configuration: -# -# config :logger, :console, -# level: :info, -# format: "$date $time [$level] $metadata$message\n", -# metadata: [:user_id] - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env}.exs" +config :segment, http_adapter: Tesla.Mock, key: "my-amazing-key" diff --git a/lib/segment.ex b/lib/segment.ex index c5ef88c..0b77bb6 100644 --- a/lib/segment.ex +++ b/lib/segment.ex @@ -1,77 +1,85 @@ defmodule Segment do - use Agent + @moduledoc """ + Client for Segment API. - @type status :: :ok | :error - - @default_endpoint "https://api.segment.io/v1/" - - @spec start_link(String.t(), String.t()) :: {Segment.status(), pid} - def start_link(key, endpoint \\ @default_endpoint) do - Agent.start_link(fn -> %{endpoint: endpoint, key: key} end, name: __MODULE__) - end - - @doc """ - The child specifications - - ## Examples - - iex> Segment.child_spec([key: "something"]) - %{ - id: Segment, - start: {Segment, :start_link, ["something", nil]} - } - - iex> Segment.child_spec([]) - ** (KeyError) key :key not found in: [] - - iex> Segment.child_spec([key: "something", endpoint: "http://example.com"]) - %{ - id: Segment, - start: {Segment, :start_link, ["something", "http://example.com"]} - } - - """ - def child_spec(arg) do - opts = [ - Keyword.fetch!(arg, :key), - Keyword.get(arg, :endpoint) - ] - - %{ - id: Segment, - start: {Segment, :start_link, opts} - } - end - - @doc """ - Returns the segment key - - ## Examples - - iex> Segment.start_link("key") - ...> Segment.key() - "key" + For usage, see `Segment.Analytics`. + For options and configuration, see `t:Segment.options/0`. """ - def key() do - Agent.get(__MODULE__, &Map.get(&1, :key)) - end - - @doc """ - Returns the segment endpoint - - ## Examples - - iex> Segment.start_link("key") - ...> Segment.endpoint() - "https://api.segment.io/v1/" - iex> Segment.start_link("key", "https://example.com") - ...> Segment.endpoint() - "https://example.com" + @default_config %Segment.Config{} + + @typedoc "The struct that will be used as payload." + @type model :: struct() + + @typedoc "Request and response body patterns that will be filtered before logging." + @type filter_body :: [{Regex.t() | String.pattern(), String.t()}] + + @typedoc "HTTP headers that will be filtered before logging." + @type filter_headers :: [String.t()] + + @typedoc """ + Options to customize the operation. + + It is possible to define options through application environment: + + # in config.exs + import Config + + config :segment, + disable_meta_logger: #{inspect(@default_config.drop_nil_fields)}, + drop_nil_fields: #{inspect(@default_config.drop_nil_fields)}, + endpoint: #{inspect(@default_config.endpoint)}, + filter_body: #{inspect(@default_config.filter_body, pretty: true)}, + http_adapter: #{inspect(@default_config.http_adapter)}, + key: "a-valid-api-key", + max_retries: #{inspect(@default_config.max_retries)}, + prefix: #{inspect(@default_config.prefix)}, + request_timeout: #{inspect(@default_config.request_timeout)}, + retry_base_delay: #{inspect(@default_config.retry_base_delay)}, + retry_jitter_factor: #{inspect(@default_config.retry_jitter_factor)}, + retry_max_delay: #{inspect(@default_config.retry_max_delay)} + + Available options: + + - `:disable_meta_logger` - If `true`, the request and response will not be logged. + Defaults to `#{inspect(@default_config.disable_meta_logger)}`. + - `:drop_nil_fields` - If `true`, removes any field with `nil` value from the request payload. + Defaults to `#{inspect(@default_config.drop_nil_fields)}`. + - `:endpoint` - The base URL for the Segment API. + Defaults to `#{inspect(@default_config.endpoint)}`. + - `:filter_body` - Request and response body patterns that will be filtered before logging. + Defaults to `#{inspect(@default_config.filter_body)}`. + - `:http_adapter` - `:Tesla` adapter for the client. + Defaults to `#{inspect(@default_config.http_adapter)}`. + - `:key` - The `x-api-key` HTTP header value. + Must be set. + - `:max_retries` - Maximum number of retries. + Defaults to `#{inspect(@default_config.max_retries)}`. + - `:prefix` - String or atom (including modules) to be used as the log prefix. + Defaults to `#{inspect(@default_config.prefix)}`. + - `:request_timeout` - Maximum amount of milliseconds to wait for a response. + Defaults to `#{inspect(@default_config.request_timeout)}`. + - `:retry_base_delay` - The base amount of milliseconds to wait before attempting a new request. + Defaults to `#{inspect(@default_config.retry_base_delay)}`. + - `:retry_jitter_factor` - Additive noise multiplier to update the retry delay. + Defaults to `#{inspect(@default_config.retry_jitter_factor)}`. + - `:retry_max_delay` - Maxium delay in milliseconds to wait before attempting a new request. + Defaults to `#{inspect(@default_config.retry_max_delay)}`. """ - def endpoint() do - Agent.get(__MODULE__, &Map.get(&1, :endpoint)) - end + @type options :: [ + disable_meta_logger: boolean(), + drop_nil_fields: boolean(), + endpoint: String.t(), + filter_body: Segment.filter_body(), + http_adapter: module(), + key: String.t(), + max_retries: non_neg_integer(), + prefix: atom() | String.t(), + request_timeout: non_neg_integer(), + retry_base_delay: non_neg_integer(), + retry_jitter_factor: non_neg_integer(), + retry_max_delay: non_neg_integer() + ] end diff --git a/lib/segment/analytics.ex b/lib/segment/analytics.ex index f56ceb2..c0e548d 100644 --- a/lib/segment/analytics.ex +++ b/lib/segment/analytics.ex @@ -1,7 +1,14 @@ defmodule Segment.Analytics do - alias HTTPoison.{Error, Response} + @moduledoc """ + Performs requests to Segment API. + """ - alias Segment.Analytics.{Batch, Context, Http, ResponseFormatter} + require Logger + + alias Segment.Analytics.Batch + alias Segment.Analytics.Context + alias Segment.Analytics.HTTP + alias Segment.Config alias Segment.Encoder def track(t = %Segment.Analytics.Track{}), do: call(t) @@ -13,7 +20,7 @@ defmodule Segment.Analytics do properties: properties, context: context } - |> call + |> call() end def identify(i = %Segment.Analytics.Identify{}), do: call(i) @@ -24,7 +31,7 @@ defmodule Segment.Analytics do traits: traits, context: context } - |> call + |> call() end def screen(s = %Segment.Analytics.Screen{}), do: call(s) @@ -36,7 +43,7 @@ defmodule Segment.Analytics do properties: properties, context: context } - |> call + |> call() end def alias(a = %Segment.Analytics.Alias{}), do: call(a) @@ -47,7 +54,7 @@ defmodule Segment.Analytics do previousId: previous_id, context: context } - |> call + |> call() end def group(g = %Segment.Analytics.Group{}), do: call(g) @@ -59,7 +66,7 @@ defmodule Segment.Analytics do traits: traits, context: context } - |> call + |> call() end def page(p = %Segment.Analytics.Page{}), do: call(p) @@ -71,17 +78,42 @@ defmodule Segment.Analytics do properties: properties, context: context } - |> call + |> call() end + @doc """ + Returns a `Task` that must be awaited on that merges the options received with the application + environment and sends the payload to the Segment API. + + The task returns `{:ok, binary}` with the raw response body if the request succeeded with + valid result. + + On failure, the task returns `{:error, binary}` with either the raw response body if the + request succeded or the inspected error otherwise. + + For options documentation, see `t:Segment.options/0`. + + ## Examples + + iex> model = %Segment.Analytics.Page{...} + ...> #{inspect(__MODULE__)}.call(model) + %Task{...} + + ...> #{inspect(__MODULE__)}.call(model, max_retries: 2) + %Task{...} + + """ + @spec call(Segment.model(), Segment.options()) :: Task.t() def call(model, options \\ []) do Task.async(fn -> + %Config{} = config = Config.get(options) + model |> generate_message_id() |> fill_context() |> wrap_in_batch() - |> Encoder.encode!(options) - |> post_to_segment(options) + |> Encoder.encode!(config) + |> post_to_segment(config) end) end @@ -100,21 +132,19 @@ defmodule Segment.Analytics do } end - defp post_to_segment(body, options) do - Http.post("", body, options) - |> ResponseFormatter.build(prefix: __MODULE__) - |> tap(&MetaLogger.log(:debug, &1)) - |> handle_response() - end + @spec post_to_segment(String.t(), Config.t()) :: HTTP.request_result() + defp post_to_segment(body, %Config{} = config) do + case HTTP.post(body, config) do + {:ok, _body} = result -> + result - defp handle_response(%{payload: %{data: %Response{body: body, status_code: status_code}}}) - when status_code in 200..299 do - {:ok, body} + {:error, _reason} = result -> + log_post_result(:error, "Segment API request failed", config) + result + end end - defp handle_response(%{payload: %{data: %Response{body: body}}}), do: {:error, body} - - defp handle_response(%{payload: %{data: %Error{reason: reason}}}) do - {:error, Enum.join([~s({"reason":"), inspect(reason), ~s("})])} - end + @spec log_post_result(Logger.level(), String.t(), Config.t()) :: :ok + defp log_post_result(log_level, message, %Config{} = config), + do: Logger.log(log_level, "[#{config.prefix}] #{message}") end diff --git a/lib/segment/analytics/http.ex b/lib/segment/analytics/http.ex index efddceb..3eb98af 100644 --- a/lib/segment/analytics/http.ex +++ b/lib/segment/analytics/http.ex @@ -1,21 +1,73 @@ -defmodule Segment.Analytics.Http do - def post(path, body, options) do - path - |> process_url(options) - |> HTTPoison.post(body, process_request_headers(options)) +defmodule Segment.Analytics.HTTP do + @moduledoc false + + alias Segment.Config + alias Tesla.Middleware + + @type request_result :: {:ok, String.t()} | {:error, String.t()} + + defguardp response_ok?(value) when is_struct(value, Tesla.Env) and value.status in 200..299 + + @spec post(String.t(), Config.t()) :: request_result() + @spec post(String.t(), String.t(), Config.t()) :: request_result() + def post(path \\ "", raw_body, %Config{} = config) do + config + |> client() + |> Tesla.post(path, raw_body) + |> handle_result() end - def process_url(path, options), - do: get_config(options, :endpoint, &Segment.endpoint/0) <> path + @spec client(Config.t()) :: Tesla.Client.t() + defp client(%Config{} = config) do + # The order matters, see Tesla.Middleware + [ + {Middleware.BaseUrl, config.endpoint}, + {Middleware.Headers, headers(config)}, + {Middleware.Retry, retry(config)}, + if(config.disable_meta_logger != true, do: {Middleware.MetaLogger, meta_logger(config)}) + ] + |> Enum.reject(&is_nil/1) + |> Tesla.client(adapter(config)) + end - def process_request_headers(options) do + @spec headers(Config.t()) :: Tesla.Env.headers() + defp headers(%Config{} = config) do [ - {"Content-Type", "application/json"}, - {"Accept", "application/json"}, - {"x-api-key", get_config(options, :key, &Segment.key/0)} + {"accept", "application/json"}, + {"content-Type", "application/json"}, + {"x-api-key", config.key} ] end - def get_config(options, key, default_func), - do: Keyword.get(options, key) || default_func.() + @spec retry(Config.t()) :: Keyword.t() + defp retry(%Config{} = config) do + [ + delay: config.retry_base_delay, + jitter_factor: config.retry_jitter_factor, + max_delay: config.retry_max_delay, + max_retries: config.max_retries, + should_retry: &should_retry?/1 + ] + end + + defp should_retry?({:ok, %Tesla.Env{} = env}) when response_ok?(env), do: false + defp should_retry?(_result), do: true + + defp meta_logger(%Config{} = config) do + [ + filter_body: config.filter_body, + filter_headers: ~w(x-api-key), + log_level: :info, + log_tag: __MODULE__ + ] + end + + @spec adapter(Config.t()) :: {module(), recv_timeout: non_neg_integer()} + defp adapter(%Config{} = config), + do: {config.http_adapter, recv_timeout: config.request_timeout} + + @spec handle_result(Tesla.Env.result()) :: request_result() + defp handle_result({:ok, %Tesla.Env{} = env}) when response_ok?(env), do: {:ok, env.body} + defp handle_result({:ok, %Tesla.Env{} = env}), do: {:error, env.body} + defp handle_result({:error, reason}), do: {:error, inspect(reason)} end diff --git a/lib/segment/analytics/response_formatter.ex b/lib/segment/analytics/response_formatter.ex deleted file mode 100644 index 28450c0..0000000 --- a/lib/segment/analytics/response_formatter.ex +++ /dev/null @@ -1,136 +0,0 @@ -defmodule Segment.Analytics.ResponseFormatter do - @moduledoc """ - Wrapper around HTTPoisong response which defines `MetaLogger.Formatter` protocol. - """ - - use TypedStruct - - alias HTTPoison.{Error, Request, Response} - alias MetaLogger.Formatter - - @replacement "[FILTERED]" - - @filter_patterns [ - {~s("email":\s?".*"), ~s("email":"#{@replacement}")}, - {~s/"address":\s?{.*?}/, ~s/"address":{}/}, - {~s("first_name":\s?".*"), ~s("first_name":"#{@replacement}")}, - {~s("last_name":\s?".*"), ~s("last_name":"#{@replacement}")}, - {~s("phone_number":\s?".*"), ~s("phone_number":"#{@replacement}")} - ] - - @derive {Formatter, filter_patterns: @filter_patterns, formatter_fn: &__MODULE__.format/1} - - @typedoc "Response formatter struct." - typedstruct do - field :payload, payload(), enforce: true - end - - @typep payload :: %{data: Response.t() | Error.t(), prefix: any()} - @typep http_response :: {:ok, Response.t()} | {:error, Error.t()} - - @doc """ - Builds `#{inspect(__MODULE__)} struct.` - - ## Examples - - iex> response = %#{inspect(Response)}{body: "foo", status_code: 200} - ...> #{inspect(__MODULE__)}.build({:ok, response}, prefix: Segment.Analytics) - %#{inspect(__MODULE__)}{ - payload: %{ - data: %#{inspect(Response)}{ - body: "foo", - status_code: 200 - }, - prefix: Segment.Analytics - } - } - - iex> error = %#{inspect(Error)}{id: nil, reason: :errconect} - ...> #{inspect(__MODULE__)}.build({:error, error}, prefix: Segment.Analytics) - %#{inspect(__MODULE__)}{ - payload: %{ - data: %#{inspect(Error)}{id: nil, reason: :errconect}, - prefix: Segment.Analytics - } - } - - """ - @spec build(http_response(), Keyword.t()) :: t() - def build(http_response, options \\ []) - - def build({status, %struct{} = response}, options) - when status in [:ok, :error] and struct in [Error, Response], - do: %__MODULE__{payload: %{data: response, prefix: Keyword.get(options, :prefix)}} - - @doc """ - Builds a log message from #{inspect(__MODULE__)} struct. - - ## Examples - - iex> payload = %{ - ...> data: %#{inspect(Response)}{status_code: 200, body: "foo"}, - ...> prefix: Segment.Analytics - ...> } - ...> #{inspect(__MODULE__)}.format(payload) - ~s([Segment.Analytics] call success: 200 with body: foo) - - iex> payload = %{ - ...> data: %#{inspect(Response)}{ - ...> request: %#{inspect(Request)}{body: "foo", url: "https://example.com"}, - ...> status_code: 300 - ...> }, - ...> prefix: Segment.Analytics - ...> } - ...> #{inspect(__MODULE__)}.format(payload) - ~s([Segment.Analytics] call failed: %HTTPoison.Response{body: nil, headers: [], ) <> - ~s(request: %HTTPoison.Request{body: "foo", headers: [], method: :get, ) <> - ~s(options: [], params: %{}, url: "https://example.com"}, request_url: nil, ) <> - ~s(status_code: 300} with request body: foo) - - iex> payload = %{ - ...> data: %#{inspect(Error)}{reason: "foo"}, - ...> prefix: Segment.Analytics, - ...> } - ...> #{inspect(__MODULE__)}.format(payload) - ~s([Segment.Analytics] call failed: %HTTPoison.Error{id: nil, reason: "foo"}) <> - ~s( with reason: "foo") - - """ - @spec format(payload()) :: String.t() - def format(%{data: %Response{status_code: status_code, body: body}, prefix: prefix}) - when status_code in 200..299 do - [ - "[", - inspect(prefix), - "] call success: ", - status_code, - " with body: ", - body - ] - |> Enum.join() - end - - def format(%{data: %Response{request: %Request{body: body}} = response, prefix: prefix}) do - [ - "[", - inspect(prefix), - "] call failed: ", - inspect(response), - " with request body: ", - body - ] - |> Enum.join() - end - - def format(%{data: %Error{reason: reason} = error, prefix: prefix}) do - [ - "[", - inspect(prefix), - "] call failed: ", - inspect(error), - " with reason: ", - inspect(reason) - ] - |> Enum.join() - end -end diff --git a/lib/segment/config.ex b/lib/segment/config.ex new file mode 100644 index 0000000..6600189 --- /dev/null +++ b/lib/segment/config.ex @@ -0,0 +1,39 @@ +defmodule Segment.Config do + @moduledoc false + + use TypedStruct + + @replacement "[FILTERED]" + + @filter_body [ + {~r/"address":\s?{.*?}/, ~s("address":{})}, + {~s("email":\s?".*"), ~s("email":"#{@replacement}")}, + {~s("first_name":\s?".*"), ~s("first_name":"#{@replacement}")}, + {~s("last_name":\s?".*"), ~s("last_name":"#{@replacement}")}, + {~s("phone_number":\s?".*"), ~s("phone_number":"#{@replacement}")} + ] + + typedstruct do + field :disable_meta_logger, boolean(), default: false + field :drop_nil_fields, boolean(), default: false + field :endpoint, String.t(), default: "https://api.segment.io/v1/" + field :filter_body, Segment.filter_body(), default: @filter_body + field :http_adapter, module(), default: Tesla.Adapter.Hackney + field :key, String.t() + field :max_retries, non_neg_integer(), default: 5 + field :prefix, atom() | String.t(), default: Segment.Analytics + field :request_timeout, non_neg_integer(), default: 5_000 + field :retry_base_delay, non_neg_integer(), default: 200 + field :retry_jitter_factor, float(), default: 0.2 + field :retry_max_delay, non_neg_integer(), default: 5_000 + end + + @spec get :: t() + @spec get(Segment.options()) :: t() + def get(fields \\ []) do + :segment + |> Application.get_all_env() + |> Keyword.merge(fields) + |> then(&struct(__MODULE__, &1)) + end +end diff --git a/lib/segment/encoder.ex b/lib/segment/encoder.ex index cd6936c..38d2b82 100644 --- a/lib/segment/encoder.ex +++ b/lib/segment/encoder.ex @@ -1,48 +1,23 @@ defmodule Segment.Encoder do - @moduledoc """ - Responsible to transforming structs into JSON strings. - """ + @moduledoc false - @doc """ - Encodes a given struct into a JSON string. + alias Segment.Config - ## Options - - * `drop_nil_fields`: If set to `true` all the struct `nil` fields will be - filtered out from the JSON string. Defaults to `false`. - - ## Examples - - iex> library = %Segment.Analytics.Context.Library{ - ...> name: "foo", - ...> version: "1.0.0" - ...> } - ...> #{inspect(__MODULE__)}.encode!(library, []) - ~s({"version":"1.0.0","transport":null,"name":"foo"}) - - iex> library = %Segment.Analytics.Context.Library{ - ...> name: "foo", - ...> version: "1.0.0" - ...> } - ...> #{inspect(__MODULE__)}.encode!(library, drop_nil_fields: true) - ~s({"version":"1.0.0","name":"foo"}) - - """ - @spec encode!(struct(), keyword()) :: String.t() - def encode!(struct, options) do + @spec encode!(struct(), Config.t()) :: String.t() + def encode!(struct, %Config{} = config) do struct |> Miss.Map.from_nested_struct([ {Date, &Date.to_iso8601/1}, {DateTime, &DateTime.to_iso8601/1}, {Decimal, &Decimal.to_float/1} ]) - |> maybe_drop_nil_fields(options) + |> maybe_drop_nil_fields(config) |> Poison.encode!() end - @spec maybe_drop_nil_fields(map(), keyword()) :: map() - defp maybe_drop_nil_fields(map, options) do - if Keyword.get(options, :drop_nil_fields) == true do + @spec maybe_drop_nil_fields(map(), Config.t()) :: map() + defp maybe_drop_nil_fields(map, %Config{} = config) do + if config.drop_nil_fields == true do drop_nil_fields_from_map(map) else map diff --git a/mix.exs b/mix.exs index b0a4d27..b28c227 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,7 @@ defmodule AnalyticsElixir.Mixfile do {:meta_logger, "~> 1.4"}, {:miss, "~> 0.1"}, {:poison, "~> 5.0"}, + {:tesla, "~> 1.5"}, {:typed_struct, "~> 0.2", runtime: false}, {:uuid, "~> 1.1"}, @@ -47,10 +48,7 @@ defmodule AnalyticsElixir.Mixfile do {:ex_doc, "~> 0.28", only: :dev, runtime: false}, # Static analysis - {:dialyxir, "~> 1.1", only: :dev, runtime: false}, - - # Test - {:bypass, "~> 2.0", only: :test} + {:dialyxir, "~> 1.1", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 1078634..a9f8399 100644 --- a/mix.lock +++ b/mix.lock @@ -1,22 +1,13 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm", "000aaeff08919e95e7aea13e4af7b2b9734577b3e6a7c50ee31ee88cab6ec4fb"}, "earmark_parser": {:hex, :earmark_parser, "1.4.22", "1de32345aab0d28bc208314937d2e69ff72ac6cacfdf89b1c0b75fc00283eb56", [:mix], [], "hexpm", "e10a2857c3b5333c503c7f95710c64f0beb2cfaa1d9de024513ad5242dc7cad5"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [: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", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [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", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "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"}, "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"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, @@ -27,13 +18,9 @@ "miss": {:hex, :miss, "0.1.5", "dcf0771834ecd82580ff02d7299563c327afa8330485d281afe0b9512adcd500", [:mix], [], "hexpm", "494aba3ba3ed8a290714b4b9ff8c8d750b76dccb63c1aca21830a74989f8a047"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [: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", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "tesla": {:hex, :tesla, "1.9.0", "8c22db6a826e56a087eeb8cdef56889731287f53feeb3f361dec5d4c8efb6f14", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c240c67e855f7e63e795bf16d6b3f5115a81d1f44b7fe4eadbf656bae0fef8a"}, "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, diff --git a/test/segment/analytics/http_test.exs b/test/segment/analytics/http_test.exs deleted file mode 100644 index 545544e..0000000 --- a/test/segment/analytics/http_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Segment.Analytics.HttpTest do - # not used in async mode because of Bypass - # test fail randomly - use ExUnit.Case - - @apikey "afakekey" - @url "something" - @body ~s({"sample": "body"}) - - setup do - bypass = Bypass.open() - start_supervised!({Segment, [key: @apikey, endpoint: endpoint_url(bypass.port)]}) - - {:ok, bypass: bypass} - end - - describe "post/4" do - test "the request sent is correct", %{bypass: bypass} do - Bypass.expect(bypass, "POST", "/#{@url}", fn conn -> - [ - {"accept", "application/json"}, - {"content-type", "application/json"}, - {"x-api-key", @apikey} - ] - |> Enum.each(fn {header, value} -> - assert [value] == Plug.Conn.get_req_header(conn, header) - end) - - assert {:ok, @body, _conn} = Plug.Conn.read_body(conn) - - Plug.Conn.resp(conn, 200, "") - end) - - Segment.Analytics.Http.post(@url, @body, []) - end - - test "when endpoint and key are given via options, " <> - "sends the request to the correct endpoint", - %{bypass: bypass} do - Bypass.expect(bypass, "POST", "/#{@url}", fn _conn -> - flunk("#{endpoint_url(bypass.port)} shouldn't be called") - end) - - Bypass.pass(bypass) - - another_bypass = Bypass.open() - another_apikey = "foobarbaz" - options = [key: another_apikey, endpoint: endpoint_url(another_bypass.port)] - - Bypass.expect(another_bypass, "POST", "/#{@url}", fn conn -> - [ - {"accept", "application/json"}, - {"content-type", "application/json"}, - {"x-api-key", another_apikey} - ] - |> Enum.each(fn {header, value} -> - assert [value] == Plug.Conn.get_req_header(conn, header) - end) - - assert {:ok, @body, _conn} = Plug.Conn.read_body(conn) - - Plug.Conn.resp(conn, 200, "") - end) - - Segment.Analytics.Http.post(@url, @body, options) - end - end - - def endpoint_url(port), do: "http://localhost:#{port}/" -end diff --git a/test/segment/analytics/model_test.exs b/test/segment/analytics/model_test.exs index 9faa104..20e7206 100644 --- a/test/segment/analytics/model_test.exs +++ b/test/segment/analytics/model_test.exs @@ -1,7 +1,5 @@ defmodule Segment.Analytics.Context.LibraryTest do - # not used in async mode because of Bypass - # test fail randomly - use ExUnit.Case + use ExUnit.Case, async: true alias Segment.Analytics.Context.Library diff --git a/test/segment/analytics/response_formatter_test.exs b/test/segment/analytics/response_formatter_test.exs deleted file mode 100644 index fa3543f..0000000 --- a/test/segment/analytics/response_formatter_test.exs +++ /dev/null @@ -1,68 +0,0 @@ -defmodule Segment.Analytics.ResponseFormatterTest do - use ExUnit.Case, async: true - - alias Segment.Analytics.ResponseFormatter, as: Subject - - doctest Subject - - alias HTTPoison.{Error, Request, Response} - - setup do - error = %Error{reason: "foo"} - - failed_response = %Response{ - status_code: 300, - request: %Request{body: "foo", url: "https://example.com"} - } - - success_response = %Response{status_code: 200, body: "foo"} - - {:ok, - error: error, - error_struct: %Subject{payload: %{data: error, prefix: Segment.Analytics}}, - failed_struct: %Subject{payload: %{data: failed_response, prefix: Segment.Analytics}}, - success_response: success_response, - success_struct: %Subject{payload: %{data: success_response, prefix: Segment.Analytics}}} - end - - describe "build/2" do - test "returns response formatter struct with response and prefix", %{ - success_response: response, - success_struct: expected_response - } do - assert Subject.build({:ok, response}, prefix: Segment.Analytics) == expected_response - end - - test "when error is given, returns response formatter struct with error and prefix", %{ - error: error, - error_struct: expected_response - } do - assert Subject.build({:error, error}, prefix: Segment.Analytics) == expected_response - end - end - - describe "format/1" do - test "when payload have a success response, resturns formatted log message", - %{success_struct: %Subject{payload: payload}} do - assert Subject.format(payload) == ~s([Segment.Analytics] call success: 200 with body: foo) - end - - test "when payload have a failed response, returns formatted log message", - %{failed_struct: %Subject{payload: payload}} do - assert Subject.format(payload) == - ~s([Segment.Analytics] call failed: %HTTPoison.Response{) <> - ~s(body: nil, headers: [], request: %HTTPoison.Request{body: ) <> - ~s("foo", headers: [], method: :get, options: [], params: %{}, ) <> - ~s(url: \"https://example.com\"}, request_url: nil, status_code:) <> - ~s( 300} with request body: foo) - end - - test "when payload have an error, returns formatted log message", %{ - error_struct: %Subject{payload: payload} - } do - assert Subject.format(payload) == - ~s([Segment.Analytics] call failed: %HTTPoison.Error{id: nil, ) <> - ~s(reason: "foo"} with reason: "foo") - end - end -end diff --git a/test/segment/analytics_test.exs b/test/segment/analytics_test.exs index 6b9f17d..2b81106 100644 --- a/test/segment/analytics_test.exs +++ b/test/segment/analytics_test.exs @@ -1,193 +1,186 @@ -defmodule Segment.Analytics.AnalyticsTest do - # not used in async mode because of Bypass - # test fail randomly - use ExUnit.Case - import ExUnit.CaptureLog - - setup do - bypass = Bypass.open() - start_supervised({Segment, [key: "123", endpoint: endpoint_url(bypass.port)]}) - version = Mix.Project.get().project[:version] +defmodule Segment.AnalyticsTest do + # Required to be sync in order to isolate logs + # and so that the created process can use the global Tesla adapter. + use ExUnit.Case, async: false - event = %Segment.Analytics.Track{ - userId: nil, - event: "test1", - properties: %{}, - context: %Segment.Analytics.Context{} - } - - expected_request_body = %{ - "batch" => [ - %{ - "anonymousId" => nil, - "context" => %{ - "app" => nil, - "ip" => nil, - "library" => %{ - "name" => "analytics_elixir", - "transport" => "http", - "version" => version - }, - "location" => nil, - "os" => nil, - "page" => nil, - "referrer" => nil, - "screen" => nil, - "timezone" => nil, - "traits" => nil, - "userAgent" => nil + import ExUnit.CaptureLog + import Tesla.Mock + + alias Segment.Analytics, as: Subject + + @default_event %Segment.Analytics.Track{ + userId: nil, + event: "test1", + properties: %{}, + context: %Segment.Analytics.Context{} + } + + @version Mix.Project.get().project[:version] + + @default_request_body %{ + "batch" => [ + %{ + "anonymousId" => nil, + "context" => %{ + "app" => nil, + "ip" => nil, + "library" => %{ + "name" => "analytics_elixir", + "transport" => "http", + "version" => @version }, - "event" => "test1", - "properties" => %{}, - "timestamp" => nil, - "type" => "track", - "userId" => nil, - "version" => nil - } - ] - } - - expected_response = ~s({"another": {"json": ["response"]}}, "address":{"city": "Amsterdam"}}) - - {:ok, - bypass: bypass, - event: event, - expected_request_body: expected_request_body, - expected_response: expected_response, - version: version} - end + "location" => nil, + "os" => nil, + "page" => nil, + "referrer" => nil, + "screen" => nil, + "timezone" => nil, + "traits" => nil, + "userAgent" => nil + }, + "event" => "test1", + "properties" => %{}, + "timestamp" => nil, + "type" => "track", + "userId" => nil, + "version" => nil + } + ] + } + + @mocked_response_body Poison.encode!(%{ + "another" => %{"json" => ["response"]}, + "address" => %{"city" => "Amsterdam"} + }) describe "call/2" do - test "sends an event, and returns the response", %{ - bypass: bypass, - event: event, - expected_request_body: expected_request_body, - expected_response: expected_response - } do - Bypass.expect(bypass, fn conn -> - {:ok, received_body, _conn} = Plug.Conn.read_body(conn) - - # messageId and sentAt are not asserted - %{"batch" => [received_event | _received_events]} = - received_body - |> Poison.decode!() - |> Map.delete("sentAt") - - received_event = Map.delete(received_event, "messageId") - - assert %{"batch" => [received_event]} == expected_request_body - Plug.Conn.resp(conn, 200, expected_response) - end) - - log = - capture_log(fn -> - task = Segment.Analytics.call(event) - assert {:ok, expected_response} == Task.await(task) - end) - - assert log =~ - ~s([Segment.Analytics] call success: 200 with body: ) <> - ~s({"another": {"json": ["response"]}}, "address":{}}) + test "sends an event, and returns the response" do + mock_request(@default_request_body, {200, [], @mocked_response_body}) + + assert_call_result(@default_event, {:ok, @mocked_response_body}, [ + [ + "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[info] [Segment.Analytics.HTTP] 200", + ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] + ]) end test "when `drop_nil_fields` option is set to `true`, sends an event without " <> - "null JSON attributes, and returns the response", - %{ - bypass: bypass, - event: event, - expected_response: expected_response, - version: version - } do - expected_request_body = %{ - "batch" => [ - %{ - "context" => %{ - "library" => %{ - "name" => "analytics_elixir", - "transport" => "http", - "version" => version - } - }, - "event" => "test1", - "properties" => %{}, - "type" => "track" - } + "null JSON attributes, and returns the response" do + mock_request( + %{ + "batch" => [ + %{ + "context" => %{ + "library" => %{ + "name" => "analytics_elixir", + "transport" => "http", + "version" => @version + } + }, + "event" => "test1", + "properties" => %{}, + "type" => "track" + } + ] + }, + {200, [], @mocked_response_body} + ) + + assert_call_result( + @default_event, + [drop_nil_fields: true], + {:ok, @mocked_response_body}, + [ + [ + "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[info] [Segment.Analytics.HTTP] 200", + ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] ] - } - - Bypass.expect(bypass, fn conn -> - {:ok, received_body, _conn} = Plug.Conn.read_body(conn) - - # messageId and sentAt are not asserted - %{"batch" => [received_event | _received_events]} = - received_body - |> Poison.decode!() - |> Map.delete("sentAt") - - received_event = Map.delete(received_event, "messageId") - - assert %{"batch" => [received_event]} == expected_request_body - Plug.Conn.resp(conn, 200, expected_response) - end) - - task = Segment.Analytics.call(event, drop_nil_fields: true) - assert {:ok, expected_response} == Task.await(task) + ) end - end - - describe "call/2 when another endpoint and key were given" do - setup %{bypass: bypass} do - Bypass.expect(bypass, fn _conn -> - flunk("#{endpoint_url(bypass.port)} shouldn't be called") - end) - - Bypass.pass(bypass) - {:ok, bypass: Bypass.open()} + test "sends an event using endpoint and key from options, and returns the response" do + mock_request(@default_request_body, {200, [], @mocked_response_body}) + + assert_call_result( + @default_event, + [endpoint: "https://some-other-endpoint.vio.io/", key: "anotherkey"], + {:ok, @mocked_response_body}, + [ + [ + "[info] [Segment.Analytics.HTTP] POST https://some-other-endpoint.vio.io/", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[info] [Segment.Analytics.HTTP] 200", + ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] + ] + ) end - test "sends an event using endpoint and key from options, and returns the response", %{ - bypass: bypass, - event: event, - expected_request_body: expected_request_body, - expected_response: expected_response - } do - Bypass.expect(bypass, fn conn -> - {:ok, received_body, _conn} = Plug.Conn.read_body(conn) - - # messageId and sentAt are not asserted - %{"batch" => [received_event | _received_events]} = - received_body - |> Poison.decode!() - |> Map.delete("sentAt") - - received_event = Map.delete(received_event, "messageId") + test "when failed to reach the server, returns error" do + mock_request(@default_request_body, {:error, :nxdomain}) + + assert_call_result(@default_event, [max_retries: 0], {:error, ":nxdomain"}, [ + [ + "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[error] [Segment.Analytics.HTTP] :nxdomain", + "[error] [Elixir.Segment.Analytics] Segment API request failed" + ]) + end + end - assert %{"batch" => [received_event]} == expected_request_body - Plug.Conn.resp(conn, 200, expected_response) - end) + defp mock_request(expected_request_body, response) do + mock_global(fn %Tesla.Env{} = env -> + request_body = + env.body + |> Poison.decode!() + |> Map.delete("sentAt") + |> Map.update!("batch", &Enum.map(&1, fn event -> Map.delete(event, "messageId") end)) - options = [key: "anotherkey", endpoint: endpoint_url(bypass.port)] + assert request_body == expected_request_body - task = Segment.Analytics.call(event, options) - assert {:ok, expected_response} == Task.await(task) - end + response + end) + end - test "when fail to reach the server returns error", %{event: event} do - expected_response = ~s({"reason":":nxdomain"}) + defp assert_call_result(event, expected_result, expected_logs) do + # asserts call/1 + assert_call_result(event, nil, expected_result, expected_logs) + # asserts call/2 + assert_call_result(event, [], expected_result, expected_logs) + end - options = [key: "invalidendpoint", endpoint: "http://invalidend.point"] + defp assert_call_result(event, options, expected_result, expected_logs) do + fn -> + result = + case options do + nil -> Subject.call(event) + options -> Subject.call(event, options) + end - log = - capture_log(fn -> - task = Segment.Analytics.call(event, options) - assert {:error, expected_response} == Task.await(task) - end) + assert %Task{} = task = result - assert log =~ - ~s(call failed: %HTTPoison.Error{id: nil, reason: :nxdomain} with reason: :nxdomain) + assert Task.await(task) == expected_result end + |> capture_log() + |> String.replace([IO.ANSI.normal(), IO.ANSI.red(), IO.ANSI.reset()], "") + |> String.split("\n", trim: true) + |> Enum.zip(List.wrap(expected_logs)) + |> Enum.each(fn {log, expected_log_substrings} -> + for expected_log_substring <- List.wrap(expected_log_substrings) do + assert log =~ expected_log_substring + end + end) end - - defp endpoint_url(port), do: "http://localhost:#{port}/" end diff --git a/test/segment/encoder_test.exs b/test/segment/encoder_test.exs index 9841a85..8658acc 100644 --- a/test/segment/encoder_test.exs +++ b/test/segment/encoder_test.exs @@ -3,8 +3,7 @@ defmodule Segment.EncoderTest do alias Segment.Encoder, as: Subject - doctest Subject - + alias Segment.Config alias Segment.Support.Factory describe "encode!/2" do @@ -15,34 +14,28 @@ defmodule Segment.EncoderTest do end test "transforms a struct into a JSON string", %{batch: batch} do - expected_response = - :batch - |> Factory.map_for() - |> Poison.encode!() - - assert Subject.encode!(batch, []) == expected_response + assert_encode!(batch, %Config{}, :batch) end test "when `drop_nil_fields` options is `true`, " <> "returns a JSON string without `null` attributes", %{batch: batch} do - expected_response = - :batch_without_null - |> Factory.map_for() - |> Poison.encode!() - - assert Subject.encode!(batch, drop_nil_fields: true) == expected_response + assert_encode!(batch, %Config{drop_nil_fields: true}, :batch_without_null) end test "when `drop_nil_fields` option is set to something different than `true`," <> "returns a JSON string with `null` attributes", %{batch: batch} do - expected_response = - :batch - |> Factory.map_for() - |> Poison.encode!() - - assert Subject.encode!(batch, drop_nil_fields: "Please don't") == expected_response + assert_encode!(batch, %Config{drop_nil_fields: "Please don't"}, :batch) end end + + defp assert_encode!(batch, %Config{} = config, result_factory) do + # For OTP 26+, it is required to parse the encoded value back to map since the order is not + # predictable. + + result = Subject.encode!(batch, config) + + assert Poison.decode!(result) == Factory.string_map_for(result_factory) + end end diff --git a/test/segment_test.exs b/test/segment_test.exs deleted file mode 100644 index c360753..0000000 --- a/test/segment_test.exs +++ /dev/null @@ -1,44 +0,0 @@ -defmodule SegmentTest do - use ExUnit.Case - - doctest Segment - - @segment_test_key System.get_env("SEGMENT_KEY") - - setup do - {:ok, config: [key: @segment_test_key, endpoint: "https://api.segment.io/v1/track"]} - end - - test "tracks debugging", %{config: config} do - start_supervised!({Segment, config}) - - t = Segment.Analytics.track("343434", "track debugging #{elem(:os.timestamp(), 2)}") - Task.await(t) - end - - describe "key/0" do - test "returns segment key", %{config: config} do - start_supervised!({Segment, config}) - - assert Segment.key() == Keyword.get(config, :key) - end - - test "when agent was not started, raises an error" do - assert {:noproc, {GenServer, :call, [Segment, {:get, _function}, 5000]}} = - catch_exit(Segment.key()) - end - end - - describe "endpoint/0" do - test "returns segment endpoint", %{config: config} do - start_supervised!({Segment, config}) - - assert Segment.endpoint() == Keyword.get(config, :endpoint) - end - - test "when agent was not started, returns nil" do - assert {:noproc, {GenServer, :call, [Segment, {:get, _function}, 5000]}} = - catch_exit(Segment.endpoint()) - end - end -end diff --git a/test/support/factory.ex b/test/support/factory.ex index 514ea4c..2d8253d 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -142,4 +142,11 @@ defmodule Segment.Support.Factory do type: "track" } end + + def string_map_for(key) do + key + |> map_for() + |> Poison.encode!() + |> Poison.decode!() + end end From 4a3c74da71c49bf33399cdd956ff541957663c14 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 12:24:14 +0200 Subject: [PATCH 02/10] Additional tests --- test/segment/analytics_test.exs | 302 +++++++++++++++++++------------- 1 file changed, 177 insertions(+), 125 deletions(-) diff --git a/test/segment/analytics_test.exs b/test/segment/analytics_test.exs index 2b81106..9bf3f16 100644 --- a/test/segment/analytics_test.exs +++ b/test/segment/analytics_test.exs @@ -1,167 +1,127 @@ defmodule Segment.AnalyticsTest do - # Required to be sync in order to isolate logs - # and so that the created process can use the global Tesla adapter. - use ExUnit.Case, async: false + use ExUnit.Case import ExUnit.CaptureLog import Tesla.Mock alias Segment.Analytics, as: Subject - @default_event %Segment.Analytics.Track{ - userId: nil, - event: "test1", - properties: %{}, - context: %Segment.Analytics.Context{} - } - @version Mix.Project.get().project[:version] - @default_request_body %{ - "batch" => [ - %{ - "anonymousId" => nil, - "context" => %{ - "app" => nil, - "ip" => nil, - "library" => %{ - "name" => "analytics_elixir", - "transport" => "http", - "version" => @version - }, - "location" => nil, - "os" => nil, - "page" => nil, - "referrer" => nil, - "screen" => nil, - "timezone" => nil, - "traits" => nil, - "userAgent" => nil - }, - "event" => "test1", - "properties" => %{}, - "timestamp" => nil, - "type" => "track", - "userId" => nil, - "version" => nil - } - ] - } - - @mocked_response_body Poison.encode!(%{ - "another" => %{"json" => ["response"]}, - "address" => %{"city" => "Amsterdam"} - }) - describe "call/2" do test "sends an event, and returns the response" do - mock_request(@default_request_body, {200, [], @mocked_response_body}) - - assert_call_result(@default_event, {:ok, @mocked_response_body}, [ - [ - "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", - ~s({"x-api-key", "[FILTERED]"}]) - ], - "[info] [Segment.Analytics.HTTP] {", - "[info] [Segment.Analytics.HTTP] 200", - ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] - ]) + response_body = mocked_response_body() + mock_request(request_body_with_nil_values(), {200, [], response_body}) + assert_call_result({:ok, response_body}, successful_logs()) end test "when `drop_nil_fields` option is set to `true`, sends an event without " <> "null JSON attributes, and returns the response" do - mock_request( - %{ - "batch" => [ - %{ - "context" => %{ - "library" => %{ - "name" => "analytics_elixir", - "transport" => "http", - "version" => @version - } - }, - "event" => "test1", - "properties" => %{}, - "type" => "track" - } - ] - }, - {200, [], @mocked_response_body} - ) + response_body = mocked_response_body() + mock_request(request_body_without_nil_values(), {200, [], response_body}) + assert_call_result([drop_nil_fields: true], {:ok, response_body}, successful_logs()) + end + + test "sends an event using endpoint and key from options, and returns the response" do + response_body = mocked_response_body() + mock_request(request_body_with_nil_values(), {200, [], response_body}) + + endpoint = "https://some-other-endpoint.vio.io/" assert_call_result( - @default_event, - [drop_nil_fields: true], - {:ok, @mocked_response_body}, - [ - [ - "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", - ~s({"x-api-key", "[FILTERED]"}]) - ], - "[info] [Segment.Analytics.HTTP] {", - "[info] [Segment.Analytics.HTTP] 200", - ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] - ] + [endpoint: endpoint, key: "anotherkey"], + {:ok, response_body}, + successful_logs(endpoint) ) end - test "sends an event using endpoint and key from options, and returns the response" do - mock_request(@default_request_body, {200, [], @mocked_response_body}) + test "when failed to request, tries again" do + response_body = mocked_response_body() + + mock_request(request_body_with_nil_values(), [ + {:error, :timeout}, + {:error, :timeout}, + {:error, :timeout}, + {200, [], response_body} + ]) assert_call_result( - @default_event, - [endpoint: "https://some-other-endpoint.vio.io/", key: "anotherkey"], - {:ok, @mocked_response_body}, - [ - [ - "[info] [Segment.Analytics.HTTP] POST https://some-other-endpoint.vio.io/", - ~s({"x-api-key", "[FILTERED]"}]) - ], - "[info] [Segment.Analytics.HTTP] {", - "[info] [Segment.Analytics.HTTP] 200", - ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] - ] + [retry_base_delay: 1], + {:ok, response_body}, + retried_successful_logs(3, ":timeout") ) end - test "when failed to reach the server, returns error" do - mock_request(@default_request_body, {:error, :nxdomain}) - - assert_call_result(@default_event, [max_retries: 0], {:error, ":nxdomain"}, [ - [ - "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", - ~s({"x-api-key", "[FILTERED]"}]) - ], - "[info] [Segment.Analytics.HTTP] {", - "[error] [Segment.Analytics.HTTP] :nxdomain", - "[error] [Elixir.Segment.Analytics] Segment API request failed" + test "when failed to request and no retries left, returns error" do + mock_request(request_body_with_nil_values(), {:error, :nxdomain}) + assert_call_result([max_retries: 0], {:error, ":nxdomain"}, failed_logs(":nxdomain")) + end + + test "with meta logger disabled, does not log requests and responses" do + response_body = mocked_response_body() + + mock_request(request_body_with_nil_values(), [ + {:error, :timeout}, + {200, [], response_body} ]) + + # No logs whatsoever on success, even with retried requests + assert_call_result( + [disable_meta_logger: true, retry_base_delay: 1], + {:ok, response_body}, + [] + ) + + # Analytics still logs if no request succeeds. + assert_call_result( + [disable_meta_logger: true, max_retries: 0], + {:error, ":timeout"}, + analytics_failed_logs() + ) end end - defp mock_request(expected_request_body, response) do + defp mock_request(expected_request_body, responses) when is_list(responses) do mock_global(fn %Tesla.Env{} = env -> - request_body = - env.body - |> Poison.decode!() - |> Map.delete("sentAt") - |> Map.update!("batch", &Enum.map(&1, fn event -> Map.delete(event, "messageId") end)) - - assert request_body == expected_request_body + assert_request_body(env, expected_request_body) + index = Process.get(:attempts, 0) + Process.put(:attempts, index + 1) + Enum.fetch!(responses, index) + end) + end + defp mock_request(expected_request_body, response) do + mock_global(fn %Tesla.Env{} = env -> + assert_request_body(env, expected_request_body) response end) end - defp assert_call_result(event, expected_result, expected_logs) do + defp assert_request_body(%Tesla.Env{} = env, expected_request_body) do + request_body = + env.body + |> Poison.decode!() + |> Map.delete("sentAt") + |> Map.update!("batch", &Enum.map(&1, fn event -> Map.delete(event, "messageId") end)) + + assert request_body == expected_request_body + end + + defp assert_call_result(expected_result, expected_logs) do # asserts call/1 - assert_call_result(event, nil, expected_result, expected_logs) + assert_call_result(nil, expected_result, expected_logs) # asserts call/2 - assert_call_result(event, [], expected_result, expected_logs) + assert_call_result([], expected_result, expected_logs) end - defp assert_call_result(event, options, expected_result, expected_logs) do + defp assert_call_result(options, expected_result, expected_logs) do + event = %Segment.Analytics.Track{ + userId: nil, + event: "test1", + properties: %{}, + context: %Segment.Analytics.Context{} + } + fn -> result = case options do @@ -183,4 +143,96 @@ defmodule Segment.AnalyticsTest do end end) end + + defp request_body_with_nil_values do + %{ + "batch" => [ + %{ + "anonymousId" => nil, + "context" => %{ + "app" => nil, + "ip" => nil, + "library" => %{ + "name" => "analytics_elixir", + "transport" => "http", + "version" => @version + }, + "location" => nil, + "os" => nil, + "page" => nil, + "referrer" => nil, + "screen" => nil, + "timezone" => nil, + "traits" => nil, + "userAgent" => nil + }, + "event" => "test1", + "properties" => %{}, + "timestamp" => nil, + "type" => "track", + "userId" => nil, + "version" => nil + } + ] + } + end + + defp request_body_without_nil_values do + %{ + "batch" => [ + %{ + "context" => %{ + "library" => %{ + "name" => "analytics_elixir", + "transport" => "http", + "version" => @version + } + }, + "event" => "test1", + "properties" => %{}, + "type" => "track" + } + ] + } + end + + defp mocked_response_body do + Poison.encode!(%{ + "another" => %{"json" => ["response"]}, + "address" => %{"city" => "Amsterdam"} + }) + end + + defp successful_logs(endpoint \\ "https://api.segment.io/v1/") do + [ + [ + "[info] [Segment.Analytics.HTTP] POST #{endpoint}", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[info] [Segment.Analytics.HTTP] 200", + ["[info] [Segment.Analytics.HTTP] {", ~s("address":{})] + ] + end + + defp failed_logs(reason) do + api_failed_logs(reason) ++ analytics_failed_logs() + end + + defp api_failed_logs(reason) do + [ + [ + "[info] [Segment.Analytics.HTTP] POST https://api.segment.io/v1/", + ~s({"x-api-key", "[FILTERED]"}]) + ], + "[info] [Segment.Analytics.HTTP] {", + "[error] [Segment.Analytics.HTTP] #{reason}" + ] + end + + defp analytics_failed_logs, + do: ["[error] [Elixir.Segment.Analytics] Segment API request failed"] + + defp retried_successful_logs(amount, reason), + do: Enum.flat_map(1..amount, fn _index -> api_failed_logs(reason) end) ++ successful_logs() end From 9163b484ad14f932b737c967bf02160fcc3fc982 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 12:40:29 +0200 Subject: [PATCH 03/10] Update CHANGELOG --- CHANGELOG.md | 29 ++++++++++++++++++++++++++--- mix.exs | 1 - mix.lock | 9 --------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0f2ac..9888930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,44 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- Replaced `HTTPoison` library with `Tesla`. + +### Removed + +- Removed `Agent` strategy in favor of configuration. See `t:Segment.options/0` for configuration + instructions. + +### Added + +- Retry mechanism for Segment API requests. +- Request and response logs through `MetaLogger`. +- Additional options available (see `t:Segment.options/0` for documentation): + - `:disable_meta_logger` + - `:filter_body` + - `:http_adapter` + - `:max_retries` + - `:request_timeout` + - `:retry_base_delay` + - `:retry_jitter_factor` + - `:retry_max_delay` + ## [1.3.1] - 2022-03-17 -## Changed +### Changed - Update the `miss` library. ## [1.3.0] - 2022-03-16 -## Changed +### Changed - Fix the encoding for Decimal, Date and DateTime structs. ## [1.2.1] - 2022-02-25 -## Changed +### Changed - Bump Poison to v5.0. diff --git a/mix.exs b/mix.exs index b28c227..e8d3c7d 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,6 @@ defmodule AnalyticsElixir.Mixfile do defp deps do [ {:decimal, "~> 2.0"}, - {:httpoison, "~> 1.8"}, {:meta_logger, "~> 1.4"}, {:miss, "~> 0.1"}, {:poison, "~> 5.0"}, diff --git a/mix.lock b/mix.lock index a9f8399..ab13190 100644 --- a/mix.lock +++ b/mix.lock @@ -1,27 +1,18 @@ %{ - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, "earmark_parser": {:hex, :earmark_parser, "1.4.22", "1de32345aab0d28bc208314937d2e69ff72ac6cacfdf89b1c0b75fc00283eb56", [:mix], [], "hexpm", "e10a2857c3b5333c503c7f95710c64f0beb2cfaa1d9de024513ad5242dc7cad5"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [: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", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [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", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "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"}, "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"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "meta_logger": {:hex, :meta_logger, "1.7.0", "60e50736bfee20f398861f3b8e4646247896bd9e435272454b92e1dc6b0139e5", [:mix], [{:miss, "~> 0.1", [hex: :miss, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: true]}], "hexpm", "29f5d40eaadde01c51369130321a0d603e4de95a634d5707485756cc7f297cc8"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "miss": {:hex, :miss, "0.1.5", "dcf0771834ecd82580ff02d7299563c327afa8330485d281afe0b9512adcd500", [:mix], [], "hexpm", "494aba3ba3ed8a290714b4b9ff8c8d750b76dccb63c1aca21830a74989f8a047"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "tesla": {:hex, :tesla, "1.9.0", "8c22db6a826e56a087eeb8cdef56889731287f53feeb3f361dec5d4c8efb6f14", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c240c67e855f7e63e795bf16d6b3f5115a81d1f44b7fe4eadbf656bae0fef8a"}, "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, } From fd94f8d53ab7764f6cd4b23d22071ccac1db57a2 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 13:35:18 +0200 Subject: [PATCH 04/10] Update README --- README.md | 128 +++++++++++++++++++++++------------------------------- 1 file changed, 54 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 4a9025a..6f84f51 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,47 @@ -analytics-elixir ![analytics-elixir](https://github.com/FindHotel/analytics-elixir/workflows/analytics-elixir/badge.svg?branch=master) -================ +# analytics-elixir ![analytics-elixir](https://github.com/FindHotel/analytics-elixir/workflows/analytics-elixir/badge.svg?branch=master) analytics-elixir is a non-supported third-party client for [Segment](https://segment.com) -## Install +## Installation -Add the following to deps section of your mix.exs: `{:segment, github: "FindHotel/analytics-elixir"}` +Add the following to deps section of your mix.exs: -and then `mix deps.get` +```elixir +{:segment, github: "FindHotel/analytics-elixir"} +``` -## Usage +And then run: -Start the Segment agent with your write_key from Segment, and the endpoint. -The __endpoint__ is optional and if omitted, it defaults to `https://api.segment.io/v1/`. -``` -Segment.start_link("YOUR_SEGMENT_KEY", "https://example.com/v1") +```sh +mix deps.get ``` -There are then two ways to call the different methods on the API. -A basic way through `Segment.Analytics` or by passing a full Struct -with all the data for the API (allowing Context and Integrations to be set) -## Usage in Phoenix - -This is how I add to a Phoenix project (may not be your preferred way) +## Usage -1. Add the following to deps section of your mix.exs: `{:segment, github: "FindHotel/analytics-elixir"}` - and then `mix deps.get` +For general usage, first define the `:key` configuration: -2. Add segment to applications list in the Phoenix project mix.exs -ie. -``` -def application do - [mod: {FormAndThread, []}, - applications: [:phoenix, :phoenix_html, :cowboy, :logger, - :phoenix_ecto, :postgrex, :segment]] -end +```elixir +config :segment, key: "your_segment_key" ``` -3. Add a config variable for your write_key (may want to make this environment dependent) -ie. -``` -config :segment, - key: "your_segment_key", - endpoint: "https://api.segment.io/v1/" -``` -The __endpoint__ is optional (as specified in the Usage section above). +> For detailed information about configuration, see `t:Segment.options/0`. -4. Start the segment agent as a child of the application in the application file under -the lib directory. In the children list add: -``` -{Segment, [Application.get_env(:segment, :key), Application.get_env(:segment, :endpoint)]} -``` +Then call `Segment.Analytics` functions to send analytics. + +There are then two ways to call the functions: + +- By using a collection of parameters +- By using the related struct as a parameter ### Track -``` + +```elixir Segment.Analytics.track(user_id, event, %{property1: "", property2: ""}) ``` + or the full way using a struct with all the possible options for the track call -``` + +```elixir %Segment.Analytics.Track{ userId: "sdsds", event: "eventname", properties: %{property1: "", property2: ""} @@ -66,11 +50,14 @@ or the full way using a struct with all the possible options for the track call ``` ### Identify -``` + +```elixir Segment.Analytics.identify(user_id, %{trait1: "", trait2: ""}) ``` + or the full way using a struct with all the possible options for the identify call -``` + +```elixir %Segment.Analytics.Identify{ userId: "sdsds", traits: %{trait1: "", trait2: ""} } @@ -78,11 +65,14 @@ or the full way using a struct with all the possible options for the identify ca ``` ### Screen -``` + +```elixir Segment.Analytics.screen(user_id, name) ``` + or the full way using a struct with all the possible options for the screen call -``` + +```elixir %Segment.Analytics.Screen{ userId: "sdsds", name: "dssd" } @@ -90,11 +80,14 @@ or the full way using a struct with all the possible options for the screen call ``` ### Alias -``` + +```elixir Segment.Analytics.alias(user_id, previous_id) ``` + or the full way using a struct with all the possible options for the alias call -``` + +```elixir %Segment.Analytics.Alias{ userId: "sdsds", previousId: "dssd" } @@ -102,11 +95,14 @@ or the full way using a struct with all the possible options for the alias call ``` ### Group -``` + +```elixir Segment.Analytics.group(user_id, group_id) ``` + or the full way using a struct with all the possible options for the group call -``` + +```elixir %Segment.Analytics.Group{ userId: "sdsds", groupId: "dssd" } @@ -114,42 +110,26 @@ or the full way using a struct with all the possible options for the group call ``` ### Page -``` + +```elixir Segment.Analytics.page(user_id, name) ``` + or the full way using a struct with all the possible options for the page call -``` + +```elixir %Segment.Analytics.Page{ userId: "sdsds", name: "dssd" } |> Segment.Analytics.page ``` -### Config as options +## Testing -You can also pass the __endpoint__ and __key__ as options to the -`Segment.Analytics.call/2` along with the struct. -``` -%Segment.Analytics.Track{ userId: "sdsds", - event: "eventname", - properties: %{property1: "", property2: ""} - } - |> Segment.Analytics.call([key: "YOUR_SEGMENT_KEY", endpoint: "https://example.com/v1"]) -``` +Clone the repository and run: -With this approach the options take precedence over configurations stored in the Segment agent. - -### Filtering null JSON attributes from request body - -You can avoid sending `null` JSON attributes to the configured Segment API endpoint by passing -`drop_nil_fields: true` to the `Segment.Analytics.call/2` function. - -## Running tests - -There are not many tests at the moment. But you can run a live test on your segment -account by running. -``` -SEGMENT_KEY=yourkey mix test +```sh +mix test ``` ## Release From cf7bcddd8f68dd36afc41ce64825ac60a536441b Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 13:39:07 +0200 Subject: [PATCH 05/10] Add specs to HTTP private functions --- lib/segment/analytics/http.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/segment/analytics/http.ex b/lib/segment/analytics/http.ex index 3eb98af..c3bb364 100644 --- a/lib/segment/analytics/http.ex +++ b/lib/segment/analytics/http.ex @@ -50,9 +50,11 @@ defmodule Segment.Analytics.HTTP do ] end + @spec should_retry?(Tesla.Env.result()) :: boolean() defp should_retry?({:ok, %Tesla.Env{} = env}) when response_ok?(env), do: false defp should_retry?(_result), do: true + @spec meta_logger(Config.t()) :: Keyword.t() defp meta_logger(%Config{} = config) do [ filter_body: config.filter_body, From 52f8bde65758275a7b03f6e9cfa880ff6f59ef74 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 15:25:28 +0200 Subject: [PATCH 06/10] Adjust typedoc --- lib/segment.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/segment.ex b/lib/segment.ex index 0b77bb6..c2d0b0b 100644 --- a/lib/segment.ex +++ b/lib/segment.ex @@ -27,10 +27,10 @@ defmodule Segment do import Config config :segment, - disable_meta_logger: #{inspect(@default_config.drop_nil_fields)}, + disable_meta_logger: #{inspect(@default_config.disable_meta_logger)}, drop_nil_fields: #{inspect(@default_config.drop_nil_fields)}, endpoint: #{inspect(@default_config.endpoint)}, - filter_body: #{inspect(@default_config.filter_body, pretty: true)}, + filter_body: #{inspect(@default_config.filter_body)}, http_adapter: #{inspect(@default_config.http_adapter)}, key: "a-valid-api-key", max_retries: #{inspect(@default_config.max_retries)}, From 6de69bf842be2743ba8d729fefccedf981835f6b Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Thu, 25 Apr 2024 16:11:51 +0200 Subject: [PATCH 07/10] Ignores nil values for Segment.Config.get/1 --- lib/segment/config.ex | 7 ++++++- test/segment/config_test.exs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 test/segment/config_test.exs diff --git a/lib/segment/config.ex b/lib/segment/config.ex index 6600189..1d6b960 100644 --- a/lib/segment/config.ex +++ b/lib/segment/config.ex @@ -33,7 +33,12 @@ defmodule Segment.Config do def get(fields \\ []) do :segment |> Application.get_all_env() - |> Keyword.merge(fields) + |> reject_nil_values() + |> Keyword.merge(reject_nil_values(fields)) |> then(&struct(__MODULE__, &1)) end + + @spec reject_nil_values(Keyword.t()) :: Keyword.t() + defp reject_nil_values(keywords), + do: Keyword.reject(keywords, fn {_key, value} -> is_nil(value) end) end diff --git a/test/segment/config_test.exs b/test/segment/config_test.exs new file mode 100644 index 0000000..ddb8d80 --- /dev/null +++ b/test/segment/config_test.exs @@ -0,0 +1,35 @@ +defmodule Segment.ConfigTest do + use ExUnit.Case, async: true + + alias Segment.Config, as: Subject + + @default_config %Subject{http_adapter: Tesla.Mock, key: "my-amazing-key"} + @keys %Subject{} + |> Map.from_struct() + |> Map.keys() + |> Enum.sort() + + describe "get/0" do + test "returns default config" do + assert Subject.get() == @default_config + end + end + + describe "get/1" do + test "with empty options, returns default config" do + assert Subject.get() == @default_config + end + + for key <- @keys do + @tag options: [{key, :value}] + test "returns config with updated #{inspect(key)}", %{options: options} do + assert Subject.get(options) == struct!(@default_config, options) + end + + @tag options: [{key, nil}] + test "ignores nil #{inspect(key)}, returns default config", %{options: options} do + assert Subject.get(options) == @default_config + end + end + end +end From 32cca7ab1ceb641109dd19f0a7152393c338bf95 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Fri, 26 Apr 2024 10:35:48 +0200 Subject: [PATCH 08/10] Parse binary values from config --- lib/segment/config.ex | 36 ++++++++++++++++++++++++++++ test/segment/config_test.exs | 46 ++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/lib/segment/config.ex b/lib/segment/config.ex index 1d6b960..6271fdd 100644 --- a/lib/segment/config.ex +++ b/lib/segment/config.ex @@ -3,6 +3,10 @@ defmodule Segment.Config do use TypedStruct + @boolean_keys ~w(disable_meta_logger drop_nil_fields)a + @float_keys ~w(retry_jitter_factor)a + @integer_keys ~w(max_retries request_timeout retry_base_delay retry_max_delay)a + @replacement "[FILTERED]" @filter_body [ @@ -28,6 +32,12 @@ defmodule Segment.Config do field :retry_max_delay, non_neg_integer(), default: 5_000 end + @spec boolean_keys :: [atom()] + def boolean_keys, do: @boolean_keys + + @spec float_keys :: [atom()] + def float_keys, do: @float_keys + @spec get :: t() @spec get(Segment.options()) :: t() def get(fields \\ []) do @@ -35,10 +45,36 @@ defmodule Segment.Config do |> Application.get_all_env() |> reject_nil_values() |> Keyword.merge(reject_nil_values(fields)) + |> Keyword.new(&parse/1) |> then(&struct(__MODULE__, &1)) end + @spec integer_keys :: [atom()] + def integer_keys, do: @integer_keys + + @spec keys :: [atom()] + def keys do + %__MODULE__{} + |> Map.from_struct() + |> Map.keys() + end + @spec reject_nil_values(Keyword.t()) :: Keyword.t() defp reject_nil_values(keywords), do: Keyword.reject(keywords, fn {_key, value} -> is_nil(value) end) + + @spec parse({atom(), any()}) :: {atom(), any()} + defp parse({key, value}) when is_binary(value) do + value = + cond do + key in @boolean_keys -> value == "true" + key in @float_keys -> String.to_float(value) + key in @integer_keys -> String.to_integer(value) + true -> value + end + + {key, value} + end + + defp parse(key_value), do: key_value end diff --git a/test/segment/config_test.exs b/test/segment/config_test.exs index ddb8d80..f39de93 100644 --- a/test/segment/config_test.exs +++ b/test/segment/config_test.exs @@ -4,10 +4,6 @@ defmodule Segment.ConfigTest do alias Segment.Config, as: Subject @default_config %Subject{http_adapter: Tesla.Mock, key: "my-amazing-key"} - @keys %Subject{} - |> Map.from_struct() - |> Map.keys() - |> Enum.sort() describe "get/0" do test "returns default config" do @@ -17,18 +13,44 @@ defmodule Segment.ConfigTest do describe "get/1" do test "with empty options, returns default config" do - assert Subject.get() == @default_config + assert Subject.get([]) == @default_config + end + + for key <- Subject.keys() do + @tag options: [{key, :value}], result: struct!(@default_config, [{key, :value}]) + test "returns config with updated #{inspect(key)} value", context do + assert Subject.get(context.options) == context.result + end + + @tag options: [{key, nil}], result: @default_config + test "ignores nil #{inspect(key)}, returns default config", context do + assert Subject.get(context.options) == context.result + end end - for key <- @keys do - @tag options: [{key, :value}] - test "returns config with updated #{inspect(key)}", %{options: options} do - assert Subject.get(options) == struct!(@default_config, options) + for key <- Subject.boolean_keys() do + @tag options: [{key, "true"}], result: struct!(@default_config, [{key, true}]) + test "returns config with parsed true string for #{inspect(key)}", context do + assert Subject.get(context.options) == context.result end - @tag options: [{key, nil}] - test "ignores nil #{inspect(key)}, returns default config", %{options: options} do - assert Subject.get(options) == @default_config + @tag options: [{key, "untrue"}], result: struct!(@default_config, [{key, false}]) + test "returns config with parsed untrue string for #{inspect(key)}", context do + assert Subject.get(context.options) == context.result + end + end + + for key <- Subject.float_keys() do + @tag options: [{key, "0.2"}], result: struct!(@default_config, [{key, 0.2}]) + test "returns config with parsed string for #{inspect(key)}", context do + assert Subject.get(context.options) == context.result + end + end + + for key <- Subject.integer_keys() do + @tag options: [{key, "10000"}], result: struct!(@default_config, [{key, 10_000}]) + test "returns config with parsed string for #{inspect(key)}", context do + assert Subject.get(context.options) == context.result end end end From aceb9b39c6ee19c05c14adcb4e3a238123230bc3 Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Fri, 26 Apr 2024 11:31:59 +0200 Subject: [PATCH 09/10] Fix typo --- lib/segment.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/segment.ex b/lib/segment.ex index c2d0b0b..94a711d 100644 --- a/lib/segment.ex +++ b/lib/segment.ex @@ -64,7 +64,7 @@ defmodule Segment do Defaults to `#{inspect(@default_config.retry_base_delay)}`. - `:retry_jitter_factor` - Additive noise multiplier to update the retry delay. Defaults to `#{inspect(@default_config.retry_jitter_factor)}`. - - `:retry_max_delay` - Maxium delay in milliseconds to wait before attempting a new request. + - `:retry_max_delay` - Maximum delay in milliseconds to wait before attempting a new request. Defaults to `#{inspect(@default_config.retry_max_delay)}`. """ From eba125f362589f3a37ec6fb2a70a25dd5b68323e Mon Sep 17 00:00:00 2001 From: Jonathan Moraes Date: Fri, 26 Apr 2024 12:47:42 +0200 Subject: [PATCH 10/10] bind caller log metadata --- lib/segment/analytics.ex | 10 +++++++++- test/segment/analytics_test.exs | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/segment/analytics.ex b/lib/segment/analytics.ex index c0e548d..2833127 100644 --- a/lib/segment/analytics.ex +++ b/lib/segment/analytics.ex @@ -105,7 +105,11 @@ defmodule Segment.Analytics do """ @spec call(Segment.model(), Segment.options()) :: Task.t() def call(model, options \\ []) do + caller_metadata = MetaLogger.metadata() + Task.async(fn -> + Logger.metadata(caller_metadata) + %Config{} = config = Config.get(options) model @@ -146,5 +150,9 @@ defmodule Segment.Analytics do @spec log_post_result(Logger.level(), String.t(), Config.t()) :: :ok defp log_post_result(log_level, message, %Config{} = config), - do: Logger.log(log_level, "[#{config.prefix}] #{message}") + do: Logger.log(log_level, "[#{log_tag(config.prefix)}] #{message}") + + @spec log_tag(atom() | String.t()) :: String.t() + defp log_tag(string) when is_binary(string), do: string + defp log_tag(value), do: inspect(value) end diff --git a/test/segment/analytics_test.exs b/test/segment/analytics_test.exs index 9bf3f16..cb043ef 100644 --- a/test/segment/analytics_test.exs +++ b/test/segment/analytics_test.exs @@ -231,7 +231,7 @@ defmodule Segment.AnalyticsTest do end defp analytics_failed_logs, - do: ["[error] [Elixir.Segment.Analytics] Segment API request failed"] + do: ["[error] [Segment.Analytics] Segment API request failed"] defp retried_successful_logs(amount, reason), do: Enum.flat_map(1..amount, fn _index -> api_failed_logs(reason) end) ++ successful_logs()