Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add retry mechanism #18

Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
elixir 1.16.0-otp-26
erlang 26.2.1
29 changes: 26 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
altjohndev marked this conversation as resolved.
Show resolved Hide resolved

- 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.

Expand Down
25 changes: 2 additions & 23 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -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"
146 changes: 77 additions & 69 deletions lib/segment.ex
Original file line number Diff line number Diff line change
@@ -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
altjohndev marked this conversation as resolved.
Show resolved Hide resolved
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)},
altjohndev marked this conversation as resolved.
Show resolved Hide resolved
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
78 changes: 54 additions & 24 deletions lib/segment/analytics.ex
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -13,7 +20,7 @@ defmodule Segment.Analytics do
properties: properties,
context: context
}
|> call
|> call()
end

def identify(i = %Segment.Analytics.Identify{}), do: call(i)
Expand All @@ -24,7 +31,7 @@ defmodule Segment.Analytics do
traits: traits,
context: context
}
|> call
|> call()
end

def screen(s = %Segment.Analytics.Screen{}), do: call(s)
Expand All @@ -36,7 +43,7 @@ defmodule Segment.Analytics do
properties: properties,
context: context
}
|> call
|> call()
end

def alias(a = %Segment.Analytics.Alias{}), do: call(a)
Expand All @@ -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)
Expand All @@ -59,7 +66,7 @@ defmodule Segment.Analytics do
traits: traits,
context: context
}
|> call
|> call()
end

def page(p = %Segment.Analytics.Page{}), do: call(p)
Expand All @@ -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

Expand All @@ -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
Loading