├── test ├── test_helper.exs └── segment_test.exs ├── config ├── test.exs └── config.exs ├── .gitignore ├── lib ├── segment │ ├── config.ex │ ├── sender.ex │ ├── types.ex │ ├── batcher.ex │ ├── analytics.ex │ └── client │ │ └── http.ex └── segment.ex ├── LICENSE ├── mix.exs ├── mix.lock └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :segment, :send_to_http, true 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :segment, 4 | sender_impl: Segment.Analytics.Batcher, 5 | max_batch_size: 100, 6 | batch_every_ms: 5000 7 | 8 | config :segment, 9 | retry_attempts: 3, 10 | retry_expiry: 10_000, 11 | retry_start: 100 12 | 13 | env_config = "#{Mix.env()}.exs" 14 | File.exists?("config/#{env_config}") && import_config(env_config) 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | segment-*.tar 24 | 25 | .DS_Store 26 | .formatter.exs 27 | .vscode/ 28 | .elixir_ls/ 29 | -------------------------------------------------------------------------------- /lib/segment/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Config do 2 | @moduledoc false 3 | 4 | def api_url do 5 | Application.get_env(:segment, :api_url, "https://api.segment.io/v1/") 6 | end 7 | 8 | def service do 9 | Application.get_env(:segment, :sender_impl, Segment.Analytics.Batcher) 10 | end 11 | 12 | def max_batch_size() do 13 | Application.get_env(:segment, :max_batch_size, 100) 14 | end 15 | 16 | def batch_every_ms() do 17 | Application.get_env(:segment, :batch_every_ms, 2000) 18 | end 19 | 20 | def send_to_http() do 21 | Application.get_env(:segment, :send_to_http, true) 22 | end 23 | 24 | def retry_attempts() do 25 | Application.get_env(:segment, :retry_attempts, 3) 26 | end 27 | 28 | def retry_expiry() do 29 | Application.get_env(:segment, :retry_expiry, 10_000) 30 | end 31 | 32 | def retry_start() do 33 | Application.get_env(:segment, :retry_start, 100) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Stuart Eccles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AnalyticsElixir.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/stueccles/analytics-elixir" 5 | @version "0.2.7" 6 | 7 | def project do 8 | [ 9 | app: :segment, 10 | version: @version, 11 | elixir: "~> 1.0", 12 | deps: deps(), 13 | description: "analytics_elixir", 14 | dialyzer: [plt_add_deps: [:app_tree]], 15 | package: package(), 16 | docs: docs() 17 | ] 18 | end 19 | 20 | def application do 21 | [applications: [:hackney, :logger, :retry, :tesla, :jason, :telemetry]] 22 | end 23 | 24 | defp deps do 25 | [ 26 | {:dialyxir, "~> 1.0.0", only: [:dev], runtime: false}, 27 | {:ex_doc, "~> 0.24", only: :dev, runtime: false}, 28 | {:hackney, "~> 1.15"}, 29 | {:jason, ">= 1.0.0"}, 30 | {:mox, "~> 0.5", only: :test}, 31 | {:retry, "~> 0.13"}, 32 | {:telemetry, "~> 0.4.2 or ~> 1.0"}, 33 | {:tesla, "~> 1.2"} 34 | ] 35 | end 36 | 37 | defp package do 38 | [ 39 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 40 | maintainers: ["Stuart Eccles"], 41 | licenses: ["MIT"], 42 | links: %{"GitHub" => @source_url} 43 | ] 44 | end 45 | 46 | defp docs do 47 | [ 48 | main: "Segment", 49 | api_reference: false, 50 | source_ref: "#{@version}", 51 | source_url: @source_url 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/segment.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment do 2 | @moduledoc "README.md" 3 | |> File.read!() 4 | |> String.split("") 5 | |> Enum.fetch!(1) 6 | 7 | @type segment_event :: 8 | Segment.Analytics.Track.t() 9 | | Segment.Analytics.Identify.t() 10 | | Segment.Analytics.Screen.t() 11 | | Segment.Analytics.Alias.t() 12 | | Segment.Analytics.Group.t() 13 | | Segment.Analytics.Page.t() 14 | 15 | @doc """ 16 | Start the configured GenServer for handling Segment events with the Segment HTTP Source API Write Key 17 | 18 | By default if nothing is configured it will start `Segment.Analytics.Batcher` 19 | """ 20 | @spec start_link(String.t()) :: GenServer.on_start() 21 | def start_link(api_key) do 22 | Segment.Config.service().start_link(api_key) 23 | end 24 | 25 | @doc """ 26 | Start the configured GenServer for handling Segment events with the Segment HTTP Source API Write Key and a custom Tesla Adapter. 27 | 28 | By default if nothing is configured it will start `Segment.Analytics.Batcher` 29 | """ 30 | @spec start_link(String.t(), Segment.Http.adapter()) :: GenServer.on_start() 31 | def start_link(api_key, adapter) do 32 | Segment.Config.service().start_link(api_key, adapter) 33 | end 34 | 35 | @spec child_spec(map()) :: map() 36 | def child_spec(opts) do 37 | Segment.Config.service().child_spec(opts) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/segment_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SegmentTest do 2 | use ExUnit.Case 3 | 4 | test "track debugging" do 5 | test_began = :erlang.system_time() 6 | 7 | :telemetry.attach_many( 8 | self(), 9 | [[:segment, :batch, :start], [:segment, :batch, :stop]], 10 | fn n, m10s, m6a, pid -> send(pid, {:telemetry, n, m10s, m6a}) end, 11 | self() 12 | ) 13 | 14 | Segment.start_link(System.get_env("SEGMENT_KEY")) 15 | 16 | Segment.Analytics.track("user1", "track debugging #{elem(:os.timestamp(), 2)}") 17 | 18 | wait_random() 19 | 20 | Segment.Analytics.identify("user1", %{ 21 | debug: "identify debugging #{elem(:os.timestamp(), 2)}" 22 | }) 23 | 24 | wait_random() 25 | 26 | Segment.Analytics.screen("user1", "screen debugging #{elem(:os.timestamp(), 2)}") 27 | 28 | wait_random() 29 | 30 | Segment.Analytics.alias("user1", "user2") 31 | 32 | wait_random() 33 | 34 | Segment.Analytics.group("user1", "group1", %{ 35 | debug: "group debugging #{elem(:os.timestamp(), 2)}" 36 | }) 37 | 38 | wait_random() 39 | 40 | Segment.Analytics.page("user1", "page debugging #{elem(:os.timestamp(), 2)}") 41 | 42 | Segment.Analytics.Batcher.flush() 43 | 44 | test_ended = :erlang.system_time() 45 | 46 | assert_received {:telemetry, [:segment, :batch, :start], %{system_time: system_time}, 47 | %{events: events}} 48 | 49 | assert_received {:telemetry, [:segment, :batch, :stop], %{duration: duration}, 50 | %{events: ^events, status: :ok, result: {:ok, env}}} 51 | 52 | assert system_time > test_began 53 | assert system_time <= test_ended 54 | assert length(events) == 6 55 | assert duration <= test_ended - test_began 56 | assert env.status == 200 57 | end 58 | 59 | defp wait_random(n \\ 1000), do: Process.sleep(:rand.uniform(n)) 60 | end 61 | -------------------------------------------------------------------------------- /lib/segment/sender.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Analytics.Sender do 2 | @moduledoc """ 3 | The `Segment.Analytics.Sender` service implementation is an alternative to the default Batcher to send every event as it is called. 4 | The HTTP call is made with an async `Task` to not block the GenServer. This will not guarantee ordering. 5 | 6 | The `Segment.Analytics.Batcher` should be preferred in production but this module will emulate the implementation of the original library if 7 | you need that or need events to be as real-time as possible. 8 | """ 9 | use GenServer 10 | alias Segment.Analytics.{Track, Identify, Screen, Alias, Group, Page} 11 | 12 | @doc """ 13 | Start the `Segment.Analytics.Sender` GenServer with an Segment HTTP Source API Write Key 14 | """ 15 | @spec start_link(String.t()) :: GenServer.on_start() 16 | def start_link(api_key) do 17 | client = Segment.Http.client(api_key) 18 | GenServer.start_link(__MODULE__, client, name: __MODULE__) 19 | end 20 | 21 | @doc """ 22 | Start the `Segment.Analytics.Sender` GenServer with an Segment HTTP Source API Write Key and a Tesla Adapter. This is mainly used 23 | for testing purposes to override the Adapter with a Mock. 24 | """ 25 | @spec start_link(String.t(), Segment.Http.adapter()) :: GenServer.on_start() 26 | def start_link(api_key, adapter) do 27 | client = Segment.Http.client(api_key, adapter) 28 | GenServer.start_link(__MODULE__, {client, :queue.new()}, name: __MODULE__) 29 | end 30 | 31 | # client 32 | @doc """ 33 | Make a call to Segment with an event. Should be of type `Track, Identify, Screen, Alias, Group or Page`. 34 | This event will be sent immediately and asynchronously 35 | """ 36 | @spec call(Segment.segment_event()) :: :ok 37 | def call(%{__struct__: mod} = event) 38 | when mod in [Track, Identify, Screen, Alias, Group, Page] do 39 | callp(event) 40 | end 41 | 42 | # GenServer Callbacks 43 | 44 | @impl true 45 | def init(client) do 46 | {:ok, client} 47 | end 48 | 49 | @impl true 50 | def handle_cast({:send, event}, client) do 51 | Task.start_link(fn -> Segment.Http.send(client, event) end) 52 | {:noreply, client} 53 | end 54 | 55 | # Helpers 56 | defp callp(event) do 57 | GenServer.cast(__MODULE__, {:send, event}) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/segment/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Analytics.Types do 2 | @moduledoc false 3 | def common_fields do 4 | [ 5 | :anonymousId, 6 | :context, 7 | :integrations, 8 | :timestamp, 9 | :userId, 10 | :version 11 | ] 12 | end 13 | end 14 | 15 | defmodule Segment.Analytics.Track do 16 | @moduledoc false 17 | @method "track" 18 | 19 | defstruct Segment.Analytics.Types.common_fields() ++ 20 | [ 21 | :event, 22 | :properties, 23 | type: @method 24 | ] 25 | 26 | @type t :: %__MODULE__{} 27 | 28 | def new(attrs) do 29 | struct(__MODULE__, attrs) 30 | end 31 | end 32 | 33 | defmodule Segment.Analytics.Identify do 34 | @moduledoc false 35 | @method "identify" 36 | 37 | defstruct Segment.Analytics.Types.common_fields() ++ 38 | [ 39 | :traits, 40 | type: @method 41 | ] 42 | 43 | @type t :: %__MODULE__{} 44 | 45 | def new(attrs) do 46 | struct(__MODULE__, attrs) 47 | end 48 | end 49 | 50 | defmodule Segment.Analytics.Alias do 51 | @moduledoc false 52 | @method "alias" 53 | 54 | defstruct Segment.Analytics.Types.common_fields() ++ 55 | [ 56 | :previousId, 57 | type: @method 58 | ] 59 | 60 | @type t :: %__MODULE__{} 61 | 62 | def new(attrs) do 63 | struct(__MODULE__, attrs) 64 | end 65 | end 66 | 67 | defmodule Segment.Analytics.Page do 68 | @moduledoc false 69 | @method "page" 70 | 71 | defstruct Segment.Analytics.Types.common_fields() ++ 72 | [ 73 | :name, 74 | :properties, 75 | type: @method 76 | ] 77 | 78 | @type t :: %__MODULE__{} 79 | 80 | def new(attrs) do 81 | struct(__MODULE__, attrs) 82 | end 83 | end 84 | 85 | defmodule Segment.Analytics.Screen do 86 | @moduledoc false 87 | @method "screen" 88 | 89 | defstruct Segment.Analytics.Types.common_fields() ++ 90 | [ 91 | :name, 92 | :properties, 93 | type: @method 94 | ] 95 | 96 | @type t :: %__MODULE__{} 97 | 98 | def new(attrs) do 99 | struct(__MODULE__, attrs) 100 | end 101 | end 102 | 103 | defmodule Segment.Analytics.Group do 104 | @moduledoc false 105 | @method "group" 106 | 107 | defstruct Segment.Analytics.Types.common_fields() ++ 108 | [ 109 | :groupId, 110 | :traits, 111 | type: @method 112 | ] 113 | 114 | @type t :: %__MODULE__{} 115 | 116 | def new(attrs) do 117 | struct(__MODULE__, attrs) 118 | end 119 | end 120 | 121 | defmodule Segment.Analytics.Context do 122 | @moduledoc false 123 | @library_name Mix.Project.get().project[:description] 124 | @library_version Mix.Project.get().project[:version] 125 | 126 | defstruct [ 127 | :active, 128 | :app, 129 | :campaign, 130 | :device, 131 | :ip, 132 | :library, 133 | :locale, 134 | :location, 135 | :network, 136 | :os, 137 | :page, 138 | :referrer, 139 | :screen, 140 | :timezone, 141 | :groupId, 142 | :traits, 143 | :userAgent 144 | ] 145 | 146 | @type t :: %__MODULE__{} 147 | 148 | def update(context = %__MODULE__{}) do 149 | %{context | library: %{name: @library_name, version: @library_version}} 150 | end 151 | 152 | def new do 153 | update(%__MODULE__{}) 154 | end 155 | 156 | def new(attrs) do 157 | struct(__MODULE__, attrs) 158 | |> update 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/segment/batcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Analytics.Batcher do 2 | @moduledoc """ 3 | The `Segment.Analytics.Batcher` module is the default service implementation for the library which uses the 4 | [Segment Batch HTTP API](https://segment.com/docs/sources/server/http/#batch) to put events in a FIFO queue and 5 | send on a regular basis. 6 | 7 | The `Segment.Analytics.Batcher` can be configured with 8 | ```elixir 9 | config :segment, 10 | max_batch_size: 100, 11 | batch_every_ms: 5000 12 | ``` 13 | * `config :segment, :max_batch_size` The maximum batch size of messages that will be sent to Segment at one time. Default value is 100. 14 | * `config :segment, :batch_every_ms` The time (in ms) between every batch request. Default value is 2000 (2 seconds) 15 | 16 | The Segment Batch API does have limits on the batch size "There is a maximum of 500KB per batch request and 32KB per call.". While 17 | the library doesn't check the size of the batch, if this becomes a problem you can change `max_batch_size` to a lower number and probably want 18 | to change `batch_every_ms` to run more frequently. The Segment API asks you to limit calls to under 50 a second, so even if you have no other 19 | Segment calls going on, don't go under 20ms! 20 | 21 | """ 22 | use GenServer 23 | alias Segment.Analytics.{Track, Identify, Screen, Alias, Group, Page} 24 | 25 | @doc """ 26 | Start the `Segment.Analytics.Batcher` GenServer with an Segment HTTP Source API Write Key 27 | """ 28 | @spec start_link(String.t()) :: GenServer.on_start() 29 | def start_link(api_key) do 30 | client = Segment.Http.client(api_key) 31 | GenServer.start_link(__MODULE__, {client, :queue.new()}, name: __MODULE__) 32 | end 33 | 34 | @doc """ 35 | Start the `Segment.Analytics.Batcher` GenServer with an Segment HTTP Source API Write Key and a Tesla Adapter. This is mainly used 36 | for testing purposes to override the Adapter with a Mock. 37 | """ 38 | @spec start_link(String.t(), Segment.Http.adapter()) :: GenServer.on_start() 39 | def start_link(api_key, adapter) do 40 | client = Segment.Http.client(api_key, adapter) 41 | GenServer.start_link(__MODULE__, {client, :queue.new()}, name: __MODULE__) 42 | end 43 | 44 | # client 45 | @doc """ 46 | Make a call to Segment with an event. Should be of type `Track, Identify, Screen, Alias, Group or Page`. 47 | This event will be queued and sent later in a batch. 48 | """ 49 | @spec call(Segment.segment_event()) :: :ok 50 | def call(%{__struct__: mod} = event) 51 | when mod in [Track, Identify, Screen, Alias, Group, Page] do 52 | enqueue(event) 53 | end 54 | 55 | @doc """ 56 | Force the batcher to flush the queue and send all the events as a big batch (warning could exceed batch size) 57 | """ 58 | @spec flush() :: :ok 59 | def flush() do 60 | GenServer.call(__MODULE__, :flush) 61 | end 62 | 63 | # GenServer Callbacks 64 | 65 | @impl true 66 | def init({client, queue}) do 67 | schedule_batch_send() 68 | {:ok, {client, queue}} 69 | end 70 | 71 | @impl true 72 | def handle_cast({:enqueue, event}, {client, queue}) do 73 | {:noreply, {client, :queue.in(event, queue)}} 74 | end 75 | 76 | @impl true 77 | def handle_call(:flush, _from, {client, queue}) do 78 | items = :queue.to_list(queue) 79 | if length(items) > 0, do: Segment.Http.batch(client, items) 80 | {:reply, :ok, {client, :queue.new()}} 81 | end 82 | 83 | @impl true 84 | def handle_info(:process_batch, {client, queue}) do 85 | length = :queue.len(queue) 86 | {items, queue} = extract_batch(queue, length) 87 | 88 | if length(items) > 0, do: Segment.Http.batch(client, items) 89 | 90 | schedule_batch_send() 91 | {:noreply, {client, queue}} 92 | end 93 | 94 | def handle_info({:ssl_closed, _msg}, state), do: {:no_reply, state} 95 | 96 | # Helpers 97 | defp schedule_batch_send do 98 | Process.send_after(self(), :process_batch, Segment.Config.batch_every_ms()) 99 | end 100 | 101 | defp enqueue(event) do 102 | GenServer.cast(__MODULE__, {:enqueue, event}) 103 | end 104 | 105 | defp extract_batch(queue, 0), 106 | do: {[], queue} 107 | 108 | defp extract_batch(queue, length) do 109 | max_batch_size = Segment.Config.max_batch_size() 110 | 111 | if length >= max_batch_size do 112 | :queue.split(max_batch_size, queue) 113 | |> split_result() 114 | else 115 | :queue.split(length, queue) |> split_result() 116 | end 117 | end 118 | 119 | defp split_result({q1, q2}), do: {:queue.to_list(q1), q2} 120 | end 121 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 4 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.17", "6f3c7e94170377ba45241d394389e800fb15adc5de51d0a3cd52ae766aafd63f", [:mix], [], "hexpm", "f93ac89c9feca61c165b264b5837bf82344d13bebc634cd575cb711e2e342023"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.25.5", "ac3c5425a80b4b7c4dfecdf51fa9c23a44877124dd8ca34ee45ff608b1c6deb9", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "688cfa538cdc146bc4291607764a7f1fcfa4cce8009ecd62de03b27197528350"}, 8 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 9 | "httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 11 | "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, 12 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 21 | "poison": {:hex, :poison, "1.5.2", "560bdfb7449e3ddd23a096929fb9fc2122f709bcc758b2d5d5a5c7d0ea848910", [:mix], []}, 22 | "retry": {:hex, :retry, "0.14.0", "751c0f6db0b5127acf346ea6f6c363ec4588320db785c62aa51776b4d280bf07", [:mix], [], "hexpm", "5c158bccf5e4de2a13d044b9930ceb7e7499c29e3fccd7c96f131a6b83a1cff3"}, 23 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 24 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 25 | "tesla": {:hex, :tesla, "1.3.3", "26ae98627af5c406584aa6755ab5fc96315d70d69a24dd7f8369cfcb75094a45", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2648f1c276102f9250299e0b7b57f3071c67827349d9173f34c281756a1b124c"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 27 | } 28 | -------------------------------------------------------------------------------- /lib/segment/analytics.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Analytics do 2 | @moduledoc """ 3 | The `Segment.Analytics` module is the easiest way to send Segment events and provides convenience methods for `track`, `identify,` `screen`, `alias`, `group`, and `page` calls 4 | 5 | The functions will then delegate the call to the configured service implementation which can be changed with: 6 | ```elixir 7 | config :segment, sender_impl: Segment.Analytics.Batcher, 8 | ``` 9 | By default (if no configuration is given) it will use `Segment.Analytics.Batcher` to send events in a batch periodically 10 | """ 11 | alias Segment.Analytics.{Track, Identify, Screen, Context, Alias, Group, Page} 12 | 13 | @type segment_id :: String.t() | integer() 14 | 15 | @doc """ 16 | Make a call to Segment with an event. Should be of type `Track, Identify, Screen, Alias, Group or Page` 17 | """ 18 | @spec send(Segment.segment_event()) :: :ok 19 | def send(%{__struct__: mod} = event) 20 | when mod in [Track, Identify, Screen, Alias, Group, Page] do 21 | call(event) 22 | end 23 | 24 | @doc """ 25 | `track` lets you record the actions your users perform. Every action triggers what Segment call an “event”, which can also have associated properties as defined in the 26 | `Segment.Analytics.Track` struct 27 | 28 | See [https://segment.com/docs/spec/track/](https://segment.com/docs/spec/track/) 29 | """ 30 | @spec track(Segment.Analytics.Track.t()) :: :ok 31 | def track(t = %Track{}) do 32 | call(t) 33 | end 34 | 35 | @doc """ 36 | `track` lets you record the actions your users perform. Every action triggers what Segment call an “event”, which can also have associated properties. `track/4` takes a `user_id`, an 37 | `event_name`, optional additional `properties` and an optional `Segment.Analytics.Context` struct. 38 | 39 | See [https://segment.com/docs/spec/track/](https://segment.com/docs/spec/track/) 40 | """ 41 | @spec track(segment_id(), String.t(), map(), Segment.Analytics.Context.t()) :: :ok 42 | def track(user_id, event_name, properties \\ %{}, context \\ Context.new()) do 43 | %Track{ 44 | userId: user_id, 45 | event: event_name, 46 | properties: properties, 47 | context: context 48 | } 49 | |> call 50 | end 51 | 52 | @doc """ 53 | `identify` lets you tie a user to their actions and record traits about them as defined in the 54 | `Segment.Analytics.Identify` struct 55 | 56 | See [https://segment.com/docs/spec/identify/](https://segment.com/docs/spec/identify/) 57 | """ 58 | @spec identify(Segment.Analytics.Identify.t()) :: :ok 59 | def identify(i = %Identify{}) do 60 | call(i) 61 | end 62 | 63 | @doc """ 64 | `identify` lets you tie a user to their actions and record traits about them. `identify/3` takes a `user_id`, optional additional `traits` and an optional `Segment.Analytics.Context` struct. 65 | 66 | See [https://segment.com/docs/spec/identify/](https://segment.com/docs/spec/identify/) 67 | """ 68 | @spec identify(segment_id(), map(), Segment.Analytics.Context.t()) :: :ok 69 | def identify(user_id, traits \\ %{}, context \\ Context.new()) do 70 | %Identify{userId: user_id, traits: traits, context: context} 71 | |> call 72 | end 73 | 74 | @doc """ 75 | `screen` let you record whenever a user sees a screen of your mobile app with properties defined in the 76 | `Segment.Analytics.Screen` struct 77 | 78 | See [https://segment.com/docs/spec/screen/](https://segment.com/docs/spec/screen/) 79 | """ 80 | @spec screen(Segment.Analytics.Screen.t()) :: :ok 81 | def screen(s = %Screen{}) do 82 | call(s) 83 | end 84 | 85 | @doc """ 86 | `screen` let you record whenever a user sees a screen of your mobile app. `screen/4` takes a `user_id`, an optional `screen_name`, optional `properties` and an optional `Segment.Analytics.Context` struct. 87 | 88 | See [https://segment.com/docs/spec/screen/](https://segment.com/docs/spec/screen/) 89 | """ 90 | @spec screen(segment_id(), String.t(), map(), Segment.Analytics.Context.t()) :: :ok 91 | def screen(user_id, screen_name \\ "", properties \\ %{}, context \\ Context.new()) do 92 | %Screen{ 93 | userId: user_id, 94 | name: screen_name, 95 | properties: properties, 96 | context: context 97 | } 98 | |> call 99 | end 100 | 101 | @doc """ 102 | `alias` is how you associate one identity with another with properties defined in the `Segment.Analytics.Alias` struct 103 | 104 | See [https://segment.com/docs/spec/alias/](https://segment.com/docs/spec/alias/) 105 | """ 106 | @spec alias(Segment.Analytics.Alias.t()) :: :ok 107 | def alias(a = %Alias{}) do 108 | call(a) 109 | end 110 | 111 | @doc """ 112 | `alias` is how you associate one identity with another. `alias/3` takes a `user_id` and a `previous_id` to map from. It also takes an optional `Segment.Analytics.Context` struct. 113 | 114 | See [https://segment.com/docs/spec/alias/](https://segment.com/docs/spec/alias/) 115 | """ 116 | @spec alias(segment_id(), segment_id(), Segment.Analytics.Context.t()) :: :ok 117 | def alias(user_id, previous_id, context \\ Context.new()) do 118 | %Alias{userId: user_id, previousId: previous_id, context: context} 119 | |> call 120 | end 121 | 122 | @doc """ 123 | The `group` call is how you associate an individual user with a group with the properties in the defined in the `Segment.Analytics.Group` struct 124 | 125 | See [https://segment.com/docs/spec/group/](https://segment.com/docs/spec/group/) 126 | """ 127 | @spec group(Segment.Analytics.Group.t()) :: :ok 128 | def group(g = %Group{}) do 129 | call(g) 130 | end 131 | 132 | @doc """ 133 | The `group` call is how you associate an individual user with a group. `group/4` takes a `user_id` and a `group_id` to associate it with. It also takes optional `traits` of the group and 134 | an optional `Segment.Analytics.Context` struct. 135 | 136 | See [https://segment.com/docs/spec/group/](https://segment.com/docs/spec/group/) 137 | """ 138 | @spec group(segment_id(), segment_id(), map(), Segment.Analytics.Context.t()) :: :ok 139 | def group(user_id, group_id, traits \\ %{}, context \\ Context.new()) do 140 | %Group{userId: user_id, groupId: group_id, traits: traits, context: context} 141 | |> call 142 | end 143 | 144 | @doc """ 145 | The `page` call lets you record whenever a user sees a page of your website with the properties defined in the `Segment.Analytics.Page` struct 146 | 147 | See [https://segment.com/docs/spec/page/](https://segment.com/docs/spec/page/) 148 | """ 149 | @spec page(Segment.Analytics.Page.t()) :: :ok 150 | def page(p = %Page{}) do 151 | call(p) 152 | end 153 | 154 | @doc """ 155 | The `page` call lets you record whenever a user sees a page of your website. `page/4` takes a `user_id` and an optional `page_name`, optional `properties` and an optional `Segment.Analytics.Context` struct. 156 | 157 | See [https://segment.com/docs/spec/page/](https://segment.com/docs/spec/page/) 158 | """ 159 | @spec page(segment_id(), String.t(), map(), Segment.Analytics.Context.t()) :: :ok 160 | def page(user_id, page_name \\ "", properties \\ %{}, context \\ Context.new()) do 161 | %Page{userId: user_id, name: page_name, properties: properties, context: context} 162 | |> call 163 | end 164 | 165 | @spec call(Segment.segment_event()) :: :ok 166 | def call(event) do 167 | Segment.Config.service().call(event) 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Segment 2 | 3 | 4 | 5 | [![hex.pm](https://img.shields.io/hexpm/v/segment.svg)](https://hex.pm/packages/segment) 6 | [![hexdocs.pm](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/segment/) 7 | [![hex.pm](https://img.shields.io/hexpm/dt/segment.svg)](https://hex.pm/packages/segment) 8 | [![hex.pm](https://img.shields.io/hexpm/l/segment.svg)](https://hex.pm/packages/segment) 9 | [![github.com](https://img.shields.io/github/last-commit/stueccles/analytics-elixir.svg)](https://github.com/stueccles/analytics-elixir/commits/master) 10 | 11 | This is a non-official third-party client for [Segment](https://segment.com). Since version `2.0` it supports 12 | batch delivery of events and retries for the API. 13 | 14 | ## Installation 15 | 16 | Add `segment` to your list of dependencies in `mix.exs`. 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:segment, "~> 0.2.6"} 22 | ] 23 | end 24 | ``` 25 | 26 | ## Usage 27 | 28 | Start the Segment agent with your write_key from Segment for a HTTP API Server Source 29 | 30 | ```elixir 31 | Segment.start_link("YOUR_WRITE_KEY") 32 | ``` 33 | 34 | There are then two ways to call the different methods on the API. 35 | A basic way through `Segment.Analytics` functions with either the full event Struct 36 | or some helper methods (also allowing Context and Integrations to be set manually). 37 | 38 | This way will use the defined GenServer implementation such as `Segment.Analytics.Batcher` which will 39 | queue and batch events to Segment. 40 | 41 | The other way is to drop down lower and use `Segment.Http` `send` and `batch` directly. This will require first creating a `client` with `Segment.Http.client/1`/`Segment.Http.client/2` 42 | 43 | ### Track 44 | 45 | ```elixir 46 | Segment.Analytics.track(user_id, event, %{property1: "", property2: ""}) 47 | ``` 48 | 49 | or the full way using a struct with all the possible options for the track call 50 | 51 | ```elixir 52 | %Segment.Analytics.Track{userId: "sdsds", event: "eventname", properties: %{property1: "", property2: ""}} 53 | |> Segment.Analytics.track 54 | ``` 55 | 56 | ### Identify 57 | 58 | ```elixir 59 | Segment.Analytics.identify(user_id, %{trait1: "", trait2: ""}) 60 | ``` 61 | 62 | Or the full way using a struct with all the possible options for the identify call. 63 | 64 | ```elixir 65 | %Segment.Analytics.Identify{userId: "sdsds", traits: %{trait1: "", trait2: ""}} 66 | |> Segment.Analytics.identify 67 | ``` 68 | 69 | ### Screen 70 | 71 | ```elixir 72 | Segment.Analytics.screen(user_id, name) 73 | ``` 74 | 75 | Or the full way using a struct with all the possible options for the screen call. 76 | 77 | ```elixir 78 | %Segment.Analytics.Screen{userId: "sdsds", name: "dssd"} 79 | |> Segment.Analytics.screen 80 | ``` 81 | 82 | ### Alias 83 | 84 | ```elixir 85 | Segment.Analytics.alias(user_id, previous_id) 86 | ``` 87 | 88 | Or the full way using a struct with all the possible options for the alias call. 89 | 90 | ```elixir 91 | %Segment.Analytics.Alias{userId: "sdsds", previousId: "dssd"} 92 | |> Segment.Analytics.alias 93 | ``` 94 | 95 | ### Group 96 | 97 | ```elixir 98 | Segment.Analytics.group(user_id, group_id) 99 | ``` 100 | 101 | Or the full way using a struct with all the possible options for the group call. 102 | 103 | ```elixir 104 | %Segment.Analytics.Group{userId: "sdsds", groupId: "dssd"} 105 | |> Segment.Analytics.group 106 | ``` 107 | 108 | ### Page 109 | 110 | ```elixir 111 | Segment.Analytics.page(user_id, name) 112 | ``` 113 | 114 | Or the full way using a struct with all the possible options for the page call. 115 | 116 | ```elixir 117 | %Segment.Analytics.Page{userId: "sdsds", name: "dssd"} 118 | |> Segment.Analytics.page 119 | ``` 120 | 121 | ### Using the Segment Context 122 | 123 | If you want to set the Context manually you should create a `Segment.Analytics.Context` struct with `Segment.Analytics.Context.new/1` 124 | 125 | ```elixir 126 | context = Segment.Analytics.Context.new(%{active: false}) 127 | Segment.Analytics.track(user_id, event, %{property1: "", property2: ""}, context) 128 | ``` 129 | 130 | ## Configuration 131 | 132 | The library has a number of configuration options you can use to overwrite default values and behaviours 133 | 134 | - `config :segment, :sender_impl` Allows selection of a sender implementation. At the moment this defaults to `Segment.Analytics.Batcher` which will send all events in batch. Change this value to `Segment.Analytics.Sender` to have all messages sent immediately (asynchronously) 135 | - `config :segment, :max_batch_size` The maximum batch size of messages that will be sent to Segment at one time. Default value is 100. 136 | - `config :segment, :batch_every_ms` The time (in ms) between every batch request. Default value is 2000 (2 seconds) 137 | - `config :segment, :retry_attempts` The number of times to retry sending against the segment API. Default value is 3 138 | - `config :segment, :retry_expiry` The maximum time (in ms) spent retrying. Default value is 10000 (10 seconds) 139 | - `config :segment, :retry_start` The time (in ms) to start the first retry. Default value is 100 140 | - `config :segment, :send_to_http` If set to `false`, the library will override the Tesla Adapter implementation to only log segment calls to `debug` but not make any actual API calls. This can be useful if you want to switch off Segment for test or dev. Default value is true 141 | - `config :segment, :tesla, :adapter` This config option allows for overriding the HTTP Adapter for Tesla (which the library defaults to Hackney).This can be useful if you prefer something else, or want to mock the adapter for testing. 142 | - `config :segment, api_url: "https://self-hosted-segment-api.com/v1/"` The Segment-compatible API endpoint that will receive your events. Defaults to `https://api.segment.io/v1/`. This setting is only useful if you are using Segment's EU instance or a Segment-compatible alternative API like [Rudderstack](https://rudderstack.com/). 143 | 144 | ## Usage in Phoenix 145 | 146 | This is how I add to a Phoenix project (may not be your preferred way) 147 | 148 | 1. Add the following to deps section of your mix.exs: `{:segment, "~> 0.2.0"}` 149 | and then `mix deps.get` 150 | 2. Add a config variable for your write_key (you may want to make this load from ENV) 151 | ie. 152 | 153 | ```elixir 154 | config :segment, 155 | write_key: "2iFFnRsCfi" 156 | ``` 157 | 158 | 3. Start the Segment GenServer in the supervised children list. In `application.ex` add to the children list: 159 | 160 | ```elixir 161 | {Segment, Application.get_env(:segment, :write_key)} 162 | ``` 163 | 164 | ## Running tests 165 | 166 | There are not many tests at the moment. if you want to run live tests on your account you need to change the config in `test.exs` to `config :segment, :send_to_http, true` and then provide your key as an environment variable. 167 | 168 | ``` 169 | SEGMENT_KEY=yourkey mix test 170 | ``` 171 | 172 | ## Telemetry 173 | 174 | This package wraps its Segment event sending in [`:telemetry.span/3`][telemetry-span-3]. You can attach to: 175 | 176 | - `[:segment, :send, :start]` 177 | - `[:segment, :send, :stop]` 178 | - `[:segment, :send, :exception]` 179 | - `[:segment, :batch, :start]` 180 | - `[:segment, :batch, :stop]` 181 | - `[:segment, :batch, :exception]` 182 | 183 | The measurements will include, in Erlang's `:native` time unit (likely `:nanosecond`): 184 | 185 | - `system_time` with `:start` events 186 | - `duration` with `:stop` and `:exception` events 187 | 188 | The metadata will include: 189 | 190 | - the original `event` or `events` with all `:send` and `:batch` events respectively 191 | - our `status` (`:ok` | `:error`) and Tesla's `result` with all `:stop` events 192 | - `error` matching `result` when it isn't `{:ok, env}` 193 | - `kind`, `reason`, and `stacktrace` with `:exception` events 194 | 195 | [telemetry-span-3]: https://hexdocs.pm/telemetry/telemetry.html#span-3 196 | -------------------------------------------------------------------------------- /lib/segment/client/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Segment.Http.Stub do 2 | @moduledoc """ 3 | The `Segment.Http.Stub` is used to replace the Tesla adapter with something that logs and returns success. It is used if `send_to_http` has been set to false 4 | """ 5 | require Logger 6 | 7 | def call(env, _opts) do 8 | Logger.debug("[Segment] HTTP API called with #{inspect(env)}") 9 | {:ok, %{env | status: 200, body: ""}} 10 | end 11 | end 12 | 13 | defmodule Segment.Http do 14 | @moduledoc """ 15 | `Segment.Http` is the underlying implementation for making calls to the Segment HTTP API. 16 | 17 | The `send/2` and `batch/4` methods can be used for sending events or batches of events to the API. The sending can be configured with 18 | ```elixir 19 | config :segment, 20 | send_to_http: true 21 | retry_attempts: 3, 22 | retry_expiry: 10_000, 23 | retry_start: 100 24 | ``` 25 | * `config :segment, :retry_attempts` The number of times to retry sending against the segment API. Default value is 3 26 | * `config :segment, :retry_expiry` The maximum time (in ms) spent retrying. Default value is 10000 (10 seconds) 27 | * `config :segment, :retry_start` The time (in ms) to start the first retry. Default value is 100 28 | * `config :segment, :send_to_http` If set to `false`, the library will override the Tesla Adapter implementation to only log segment calls to `debug` but not make any actual API calls. This can be useful if you want to switch off Segment for test or dev. Default value is true 29 | 30 | The retry uses a linear back-off strategy when retrying the Segment API. 31 | 32 | Additionally a different Tesla Adapter can be used if you want to use something other than Hackney. 33 | 34 | * `config :segment, :tesla, :adapter` This config option allows for overriding the HTTP Adapter for Tesla (which the library defaults to Hackney).This can be useful if you prefer something else, or want to mock the adapter for testing. 35 | 36 | """ 37 | @type client :: Tesla.Client.t() 38 | @type adapter :: Tesla.Client.adapter() 39 | 40 | require Logger 41 | use Retry 42 | 43 | @doc """ 44 | Create a Tesla client with the Segment Source Write API Key 45 | """ 46 | @spec client(String.t()) :: client() 47 | def client(api_key) do 48 | adapter = 49 | case Segment.Config.send_to_http() do 50 | true -> 51 | Application.get_env(:segment, :tesla)[:adapter] || 52 | {Tesla.Adapter.Hackney, [recv_timeout: 30_000]} 53 | 54 | false -> 55 | {Segment.Http.Stub, []} 56 | end 57 | 58 | client(api_key, adapter) 59 | end 60 | 61 | @doc """ 62 | Create a Tesla client with the Segment Source Write API Key and the given Tesla adapter 63 | """ 64 | @spec client(String.t(), adapter()) :: client() 65 | def client(api_key, adapter) do 66 | middleware = [ 67 | {Tesla.Middleware.BaseUrl, Segment.Config.api_url()}, 68 | Tesla.Middleware.JSON, 69 | {Tesla.Middleware.BasicAuth, %{username: api_key, password: ""}} 70 | ] 71 | 72 | Tesla.client(middleware, adapter) 73 | end 74 | 75 | @doc """ 76 | Send a list of Segment events as a batch 77 | """ 78 | @spec send(client(), list(Segment.segment_event())) :: :ok | :error 79 | def send(client, events) when is_list(events), do: batch(client, events) 80 | 81 | @spec send(client(), Segment.segment_event()) :: :ok | :error 82 | def send(client, event) do 83 | :telemetry.span([:segment, :send], %{event: event}, fn -> 84 | tesla_result = 85 | make_request(client, event.type, prepare_events(event), Segment.Config.retry_attempts()) 86 | 87 | case process_send_post_result(tesla_result) do 88 | :ok -> 89 | {:ok, %{event: event, status: :ok, result: tesla_result}} 90 | 91 | :error -> 92 | {:error, %{event: event, status: :error, error: tesla_result, result: tesla_result}} 93 | end 94 | end) 95 | end 96 | 97 | defp process_send_post_result(tesla_result) do 98 | case tesla_result do 99 | {:ok, %{status: status}} when status == 200 -> 100 | :ok 101 | 102 | {:ok, %{status: status}} when status == 400 -> 103 | Logger.error("[Segment] Call Failed. JSON too large or invalid") 104 | :error 105 | 106 | {:error, err} -> 107 | Logger.error( 108 | "[Segment] Call Failed after #{Segment.Config.retry_attempts()} retries. #{inspect(err)}" 109 | ) 110 | 111 | :error 112 | 113 | err -> 114 | Logger.error("[Segment] Call Failed #{inspect(err)}") 115 | :error 116 | end 117 | end 118 | 119 | @doc """ 120 | Send a list of Segment events as a batch. 121 | 122 | The `batch` function takes optional arguments for context and integrations which can 123 | be applied to the entire batch of events. See [Segment's docs](https://segment.com/docs/sources/server/http/#batch) 124 | """ 125 | @spec batch(client(), list(Segment.segment_event()), map() | nil, map() | nil) :: :ok | :error 126 | def batch(client, events, context \\ nil, integrations \\ nil) do 127 | :telemetry.span([:segment, :batch], %{events: events}, fn -> 128 | data = 129 | %{batch: prepare_events(events)} 130 | |> add_if(:context, context) 131 | |> add_if(:integrations, integrations) 132 | 133 | tesla_result = make_request(client, "batch", data, Segment.Config.retry_attempts()) 134 | 135 | case process_batch_post_result(tesla_result, events) do 136 | :ok -> 137 | {:ok, %{events: events, status: :ok, result: tesla_result}} 138 | 139 | :error -> 140 | {:error, %{events: events, status: :error, error: tesla_result, result: tesla_result}} 141 | end 142 | end) 143 | end 144 | 145 | defp process_batch_post_result(tesla_result, events) do 146 | case tesla_result do 147 | {:ok, %{status: status}} when status == 200 -> 148 | :ok 149 | 150 | {:ok, %{status: status}} when status == 400 -> 151 | Logger.error( 152 | "[Segment] Batch call of #{length(events)} events failed. JSON too large or invalid" 153 | ) 154 | 155 | :error 156 | 157 | {:error, err} -> 158 | Logger.error( 159 | "[Segment] Batch call of #{length(events)} events failed after #{Segment.Config.retry_attempts()} retries. #{inspect(err)}" 160 | ) 161 | 162 | :error 163 | 164 | err -> 165 | Logger.error("[Segment] Batch callof #{length(events)} events failed #{inspect(err)}") 166 | :error 167 | end 168 | end 169 | 170 | defp make_request(client, url, data, retries) when retries > 0 do 171 | retry with: 172 | linear_backoff(Segment.Config.retry_start(), 2) 173 | |> cap(Segment.Config.retry_expiry()) 174 | |> Stream.take(retries) do 175 | Tesla.post(client, url, data) 176 | after 177 | result -> result 178 | else 179 | error -> error 180 | end 181 | end 182 | 183 | defp make_request(client, url, data, _retries) do 184 | Tesla.post(client, url, data) 185 | end 186 | 187 | defp prepare_events(items) when is_list(items), do: Enum.map(items, &prepare_events/1) 188 | 189 | defp prepare_events(item) do 190 | Map.from_struct(item) 191 | |> prep_context() 192 | |> add_sent_at() 193 | |> drop_nils() 194 | end 195 | 196 | defp drop_nils(map) do 197 | map 198 | |> Enum.filter(fn 199 | {_, %{} = item} when map_size(item) == 0 -> false 200 | {_, nil} -> false 201 | {_, _} -> true 202 | end) 203 | |> Enum.into(%{}) 204 | end 205 | 206 | defp prep_context(%{context: nil} = map), 207 | do: %{map | context: map_content(Segment.Analytics.Context.new())} 208 | 209 | defp prep_context(%{context: context} = map), do: %{map | context: map_content(context)} 210 | 211 | defp prep_context(map), 212 | do: Map.put_new(map, :context, map_content(Segment.Analytics.Context.new())) 213 | 214 | defp map_content(%Segment.Analytics.Context{} = context), do: Map.from_struct(context) 215 | defp map_content(context) when is_map(context), do: context 216 | 217 | defp add_sent_at(%{sentAt: nil} = map), do: Map.put(map, :sentAt, DateTime.utc_now()) 218 | defp add_sent_at(map), do: Map.put_new(map, :sentAt, DateTime.utc_now()) 219 | 220 | defp add_if(map, _key, nil), do: map 221 | defp add_if(map, key, value), do: Map.put_new(map, key, value) 222 | end 223 | --------------------------------------------------------------------------------