├── .credo.exs ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── elixir.yml │ └── release-asset.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── mixpanel.ex └── mixpanel │ ├── client.ex │ ├── client │ └── state.ex │ ├── config.ex │ ├── http.ex │ ├── http │ ├── hackney.ex │ ├── httpc.ex │ └── noop.ex │ ├── queue.ex │ ├── queue │ └── simple.ex │ ├── supervisor.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── property ├── mixpanel │ └── queue │ │ └── simple_test.exs └── test_helper.exs └── test ├── http_test.exs ├── mixpanel ├── client_test.exs ├── config_test.exs └── supervisor_test.exs ├── mixpanel_test.exs ├── support ├── mixpanel.ex ├── plug.ex ├── selfsigned.pem ├── selfsigned_key.pem └── telemetry_collector.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | checks: %{ 6 | disabled: [ 7 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []} 8 | ] 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test,property}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 3 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | open-pull-requests-limit: 1 13 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, reopened, synchronize, ready_for_review] 9 | 10 | env: 11 | COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} 12 | BRANCH: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }} 13 | 14 | jobs: 15 | test: 16 | # This condition ensures that this job will not run on pull requests in draft state 17 | if: github.event_name != 'pull_request' || !github.event.pull_request.draft 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-20.04, ubuntu-22.04] 22 | otp: [22, 23, 24, 25, 26] 23 | elixir: [1.12, 1.13, 1.14, 1.15, 1.16.0-rc.0] 24 | exclude: 25 | - otp: 26 26 | elixir: 1.14 27 | - otp: 26 28 | elixir: 1.13 29 | - otp: 26 30 | elixir: 1.12 31 | - otp: 25 32 | elixir: 1.12 33 | - otp: 23 34 | elixir: 1.16.0-rc.0 35 | - otp: 23 36 | elixir: 1.15 37 | - otp: 22 38 | elixir: 1.16.0-rc.0 39 | - otp: 22 40 | elixir: 1.15 41 | - otp: 22 42 | elixir: 1.14 43 | - otp: 26 44 | os: ubuntu-20.04 45 | - otp: 25 46 | os: ubuntu-20.04 47 | - otp: 24 48 | os: ubuntu-20.04 49 | - otp: 23 50 | os: ubuntu-22.04 51 | - otp: 22 52 | os: ubuntu-22.04 53 | runs-on: ${{matrix.os}} 54 | name: test|OTP ${{matrix.otp}}|Elixir ${{matrix.elixir}}|${{matrix.os}} 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | ref: ${{env.BRANCH}} 61 | - uses: erlef/setup-beam@v1.17.1 62 | with: 63 | otp-version: ${{matrix.otp}} 64 | elixir-version: ${{matrix.elixir}} 65 | - name: Disable compile warnings 66 | run: echo "::remove-matcher owner=elixir-mixCompileWarning::" 67 | - name: Retrieve mix dependencies cache 68 | uses: actions/cache@v3 69 | id: mix-cache 70 | with: 71 | path: | 72 | deps 73 | _build 74 | key: test-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 75 | restore-keys: test-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 76 | - name: Install Dependencies 77 | if: steps.mix-cache.outputs.cache-hit != 'true' 78 | run: | 79 | mix deps.get 80 | mix deps.compile 81 | - name: Compile project 82 | run: mix compile 83 | - name: Compile project (:test) 84 | run: MIX_ENV=test mix compile 85 | - name: Compile project (:property) 86 | run: MIX_ENV=property mix compile 87 | - name: Run property-based tests 88 | run: mix test.property --cover --export-coverage property-coverage 89 | - name: Run tests 90 | run: mix coveralls.github --import-cover cover 91 | lint: 92 | # This condition ensures that this job will not run on pull requests in draft state 93 | if: github.event_name != 'pull_request' || !github.event.pull_request.draft 94 | runs-on: ubuntu-22.04 95 | name: lint|OTP 26|Elixir 1.15|ubuntu-22.04 96 | steps: 97 | - uses: actions/checkout@v4 98 | with: 99 | ref: ${{env.BRANCH}} 100 | - uses: erlef/setup-beam@v1.17.1 101 | with: 102 | otp-version: 26 103 | elixir-version: 1.15 104 | - name: Disable compile warnings 105 | run: echo "::remove-matcher owner=elixir-mixCompileWarning::" 106 | - name: Retrieve mix dependencies cache 107 | uses: actions/cache@v3 108 | id: mix-cache 109 | with: 110 | path: | 111 | deps 112 | _build 113 | key: test-ubuntu-22.04-26-1.15-${{ hashFiles('**/mix.lock') }} 114 | restore-keys: test-ubuntu-22.04-26-1.15- 115 | - name: Install Dependencies 116 | if: steps.mix-cache.outputs.cache-hit != 'true' 117 | run: | 118 | mix deps.get 119 | mix deps.compile 120 | - name: Compile project 121 | run: mix compile --warnings-as-errors 122 | - name: Check for unused dependencies 123 | run: mix deps.unlock --check-unused 124 | - name: Check formatting 125 | run: mix format --check-formatted 126 | - name: Check style 127 | run: mix credo --strict 128 | - name: Check compilation cycles 129 | run: mix xref graph --format cycles --fail-above 0 130 | - name: Check unused code 131 | run: mix compile.unused --severity warning --warnings-as-errors 132 | - name: Retrieve PLT cache 133 | uses: actions/cache@v3 134 | id: plt-cache 135 | with: 136 | path: priv/plts 137 | key: ubuntu-22.04-26-1.15-plts-${{ hashFiles('**/mix.lock') }} 138 | restore-keys: ubuntu-22.04-26-1.15-plts- 139 | - name: Create PLTs 140 | run: | 141 | mkdir -p priv/plts 142 | mix dialyzer --plt 143 | - name: Run dialyzer 144 | run: mix dialyzer --format github 145 | -------------------------------------------------------------------------------- /.github/workflows/release-asset.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Create draft release 11 | runs-on: ubuntu-22.04 12 | outputs: 13 | upload_url: ${{steps.create_release.outputs.upload_url}} 14 | steps: 15 | - name: Create Release 16 | id: create_release 17 | uses: softprops/action-gh-release@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | name: Release ${{ github.ref }} 23 | draft: true 24 | prerelease: ${{ contains(github.ref, '-rc.') }} 25 | build: 26 | name: Build and publish release asset 27 | runs-on: ubuntu-22.04 28 | needs: release 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | ref: ${{env.BRANCH}} 33 | - name: Validate version 34 | run: | 35 | VERSION="$(cat ./VERSION)" 36 | if [[ "$GITHUB_REF_NAME" != "v$VERSION" ]]; then 37 | echo "VERSION $VERSION does not match commit tag $GITHUB_REF_NAME" 38 | exit 1 39 | fi 40 | - uses: erlef/setup-beam@v1.17.1 41 | with: 42 | elixir-version: 1.15 43 | otp-version: 26 44 | - name: Retrieve mix dependencies cache 45 | uses: actions/cache@v3 46 | id: mix-cache 47 | with: 48 | path: | 49 | deps 50 | _build 51 | key: test-ubuntu-22.04-26-1.15-${{ hashFiles('**/mix.lock') }} 52 | restore-keys: test-ubuntu-22.04-26-1.15- 53 | - name: Install Dependencies 54 | if: steps.mix-cache.outputs.cache-hit != 'true' 55 | run: | 56 | mix deps.get 57 | mix deps.compile 58 | - name: Compile project 59 | run: mix compile 60 | - name: Build release 61 | run: | 62 | mix hex.build -o ./release 63 | - name: Upload Release Asset 64 | uses: softprops/action-gh-release@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ needs.release.outputs.upload_url }} 69 | asset_path: ./release 70 | asset_name: mixpanel-api-ex-${{ github.ref_name }}.tar 71 | asset_content_type: application/x-tar 72 | -------------------------------------------------------------------------------- /.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 | mixpanel_api_ex-*.tar 24 | 25 | # Ignore dialyzer caches 26 | /priv/plts/ 27 | /propcheck_counter_examples 28 | /.tool-versions 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.4 4 | 5 | * Support for Erlang/OTP 22, 23, 24, 25 has been added 6 | * Elixir 1.12, 1.13, 1.14 are now supported 7 | * Elixir 1.16.0-rc.0 is supported on OTP 24, 25, 26 as well 8 | * If a project token present in properties it won't be overwritten 9 | * Dynamic starting/stopping of client processes was implemented 10 | 11 | ## 1.2.3 12 | 13 | * NoOp was added to the list of known adapters 14 | * mix_unused was listed as dev only dependency 15 | 16 | ## 1.2.2 17 | 18 | * Add VERSION to list of the package's extra files 19 | 20 | ## 1.2.1 21 | 22 | * Fixes name of the default HTTP adapter 23 | 24 | ## 1.2.0 25 | 26 | ### Breaking changes 27 | 28 | * Now you can configure the library in a such a way that it can be used to 29 | stream to multiple Mixpanel accounts simultaneously. Consult with README.md on 30 | how to use new API. 31 | 32 | ### Improvements 33 | 34 | * Introduces Telemetry support. 35 | 36 | ## 1.1.0 37 | 38 | ### Improvements 39 | 40 | * Supports Elixir 1.15 and Erlang/OTP 26: all compilation errors and warnings 41 | were fixed. 42 | * User facing API now supports NaiveDateTime, DateTime, as well as Erlang's 43 | timestamps and Erlang's Calendar `t:datetime()`. 44 | * Batch variant of engage function (`Mixpanel.engage/2`) has been added. 45 | * `Mixpanel.create_alias/2` has been added. 46 | * Poison has been replaced with Jason. 47 | * `base_url` option has been added which enables selection EU Residency servers 48 | (or enables use of proxies of your choice). 49 | * Supports plug-able adapters for http libraries via `:http_adapter` 50 | configuration parameter: implements default one using `httpc` OTP library and 51 | and another one using Hackney library (won't be available until it listed as 52 | dependency in your project's mix file). 53 | * If a request to Mixpanel backend fails for any reason, then it will be retries 54 | up to 3 times. 55 | * All dependencies have been upgraded to their latest versions. 56 | 57 | ### Breaking changes 58 | 59 | * Mentions of the Token has been replaced with Project Token to reflect the 60 | official docs. Rename `token` to `project_token` in the `config.exs`. 61 | 62 | ### Deprecation 63 | 64 | * HTTPoison has been removed 65 | * Mock library was made obsolete 66 | * InchEx integration was removed 67 | * Dogma has been dropped 68 | 69 | ### General code quality improvements 70 | 71 | * Travis has been obsolete and was replaced by GitHub actions. 72 | * Now the library uses Dependabot. 73 | * All typespecs were refined and thus improved the documentation. 74 | * Dialyzer errors have been fixed. 75 | * Credo's warnings and errors have been resolved 76 | * A lot of code repetition has been eliminated. 77 | * Better validation of user provided options has been added to user facing API 78 | functions. 79 | * Since the library employs behaviours to implement HTTP client adapters, the 80 | test suit was moved to Mox mocking framework and thus was simplified. 81 | * To test HTTP adapters, Bandit running in HTTPS mode was used. 82 | * Due to changes listed above, the test suite coverage was greatly improved. 83 | 84 | ## 0.8.4 85 | 86 | * Update deps 87 | 88 | ## 0.8.0 89 | 90 | Initial release 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mikalai Seva 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mixpanel 2 | 3 | [![Module Version](https://img.shields.io/hexpm/v/mixpanel_api_ex.svg)](https://hex.pm/packages/mixpanel_api_ex) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/mixpanel_api_ex/) 5 | [![Total Downloads](https://img.shields.io/hexpm/dt/mixpanel_api_ex.svg)](https://hex.pm/packages/mixpanel_api_ex) 6 | [![License](https://img.shields.io/hexpm/l/mixpanel_api_ex.svg)](https://github.com/asakura/mixpanel_api_ex/blob/master/LICENSE) 7 | [![Last Updated](https://img.shields.io/github/last-commit/asakura/mixpanel_api_ex.svg)](https://github.com/asakura/mixpanel_api_ex/commits/master) 8 | [![Coverage Status](https://coveralls.io/repos/github/asakura/mixpanel_api_ex/badge.svg?branch=master)](https://coveralls.io/github/asakura/mixpanel_api_ex?branch=master) 9 | [![CI](https://github.com/asakura/mixpanel_api_ex/actions/workflows/elixir.yml/badge.svg)](https://github.com/asakura/mixpanel_api_ex/actions) 10 | 11 | This is a non-official third-party Elixir client for the 12 | [Mixpanel](https://mixpanel.com/). 13 | 14 | > Note that this README refers to the `master` branch of `mixpanel_api_ex`, not 15 | the latest released version on Hex. See 16 | [the documentation](https://hexdocs.pm/mixpanel_api_ex) for the documentation 17 | of the version you're using. 18 | 19 | For the list of changes, checkout the latest 20 | [release notes](https://github.com/asakura/mixpanel_api_ex/CHANGESET.md). 21 | 22 | ## Installation 23 | 24 | Add `mixpanel_api_ex` to your list of dependencies in `mix.exs`. 25 | 26 | ```elixir 27 | def deps do 28 | [ 29 | {:mixpanel_api_ex, "~> 1.2"}, 30 | 31 | # optional, but recommended adapter 32 | {:hackney, "~> 1.20"} 33 | ] 34 | end 35 | ``` 36 | 37 | > The default adapter is Erlang's built-in `httpc`, but it is not recommended to 38 | use it in a production environment as it does not validate SSL certificates 39 | among other issues. 40 | 41 | And ensure that `mixpanel_api_ex` is started before your application: 42 | 43 | ```elixir 44 | def application do 45 | [applications: [:mixpanel_api_ex, :my_app]] 46 | end 47 | ``` 48 | 49 | ## Usage Example 50 | 51 | Define an interface module with `use Mixpanel`. 52 | 53 | ```elixir 54 | # lib/my_app/mixpanel.ex 55 | 56 | defmodule MyApp.Mixpanel do 57 | use Mixpanel 58 | end 59 | ``` 60 | 61 | Configure the interface module in `config/config.exs`. 62 | 63 | ```elixir 64 | # config/config.exs 65 | 66 | config :mixpanel_api_ex, MyApp.Mixpanel, 67 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN") 68 | ``` 69 | 70 | And then to track an event: 71 | 72 | ```elixir 73 | MyApp.Mixpanel.track("Signed up", %{"Referred By" => "friend"}, distinct_id: "13793") 74 | # => :ok 75 | ``` 76 | 77 | ## TOC 78 | 79 | - [Configuration](#configuration) 80 | - [EU Data Residency](#eu-data-residency) 81 | - [Supported HTTP clients](#supported-http-clients) 82 | - [Running multiple instances](#running-multiple-instances) 83 | - [Running tests](#running-tests) 84 | - [Runtime/Dynamic configuration](#runtime-dynamic-configuration) 85 | - [Telemetry](#telemetry) 86 | - [Usage](#usage) 87 | - [Contributing](#contributing) 88 | - [License](#license) 89 | 90 | ## Configuration 91 | 92 | ### EU Data Residency 93 | 94 | By default `mixpanel_api_ex` sends data to Mixpanels's US Servers. However, 95 | this can be changes via `:base_url` parameter: 96 | 97 | ```elixir 98 | # config/config.exs 99 | 100 | config :mixpanel_api_ex, MyApp.Mixpanel, 101 | base_url: "https://api-eu.mixpanel.com", 102 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN") 103 | ``` 104 | 105 | `:base_url` is not limited specifically to this URL. So if you need you can 106 | provide an proxy address to route Mixpanel events via. 107 | 108 | ### Supported HTTP clients 109 | 110 | At the moment `httpc` and `hackney` libraries are supported. `:http_adapter` 111 | param can be used to select which HTTP adapter you want to use. 112 | 113 | ```elixir 114 | # config/config.exs 115 | 116 | config :mixpanel_api_ex, MyApp.Mixpanel, 117 | http_adapter: Mixpanel.HTTP.Hackney, 118 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN") 119 | ``` 120 | 121 | > The default adapter is Erlang's built-in `httpc`, but it is not recommended to 122 | use it in a production environment as it does not validate SSL certificates 123 | among other issues. 124 | 125 | ### Running multiple instances 126 | 127 | You can configure multiple instances to be used by different applications within 128 | your VM. For instance the following example demonstrates having separate client 129 | which is used specifically to sending data to Mixpanel's EU servers. 130 | 131 | ```elixir 132 | # config/config.exs 133 | 134 | config :mixpanel_api_ex, MyApp.Mixpanel, 135 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN") 136 | 137 | config :mixpanel_api_ex, MyApp.Mixpanel.EU, 138 | base_url: "https://api-eu.mixpanel.com", 139 | project_token: System.get_env("MIXPANEL_EU_PROJECT_TOKEN") 140 | ``` 141 | 142 | ```elixir 143 | # lib/my_app/mixpanel.ex 144 | 145 | defmodule MyApp.Mixpanel do 146 | use Mixpanel 147 | end 148 | ``` 149 | 150 | ```elixir 151 | # lib/my_app/mixpanel_eu.ex 152 | 153 | defmodule MyApp.MixpanelEU do 154 | use Mixpanel 155 | end 156 | ``` 157 | 158 | ### Running tests 159 | 160 | Other than not running `mixpanel_api_ex` application in test environment you 161 | have got two other options. Which one you need to use depends on if you want the 162 | client process running or not. 163 | 164 | If you prefer the client process to be up and running during the test suite 165 | running you may provide `Mixpanel.HTTP.NoOp` adapter to `:http_adapter` param. 166 | As the adapter's name suggests it won't do any actual work sending data to 167 | Mixpanel, but everything else will be running (including emitting Telemetry's 168 | event). 169 | 170 | ```elixir 171 | # config/test.exs 172 | 173 | config :mixpanel_api_ex, MyApp.Mixpanel, 174 | project_token: "", 175 | http_adapter: Mixpanel.HTTP.NoOp 176 | ``` 177 | 178 | The second options would be simply assign `nil` as configuration value. This way 179 | that client won't be started by the application supervisor. 180 | 181 | ```elixir 182 | # config/test.exs 183 | 184 | config :mixpanel_api_ex, MyApp.Mixpanel, nil 185 | ``` 186 | 187 | ### Runtime/Dynamic Configuration 188 | 189 | In cases when you don't know upfront how many client instances you need and what 190 | project tokens to use (for instance this information is read from a database or 191 | a external configuration file during application startup) you can use 192 | `Mixpanel.start_client/1` and `Mixpanel.terminate_client/1` to manually run and 193 | kill instances when needed. 194 | 195 | For instance this would start `MyApp.Mixpanel.US` named client with `"token"` project token: 196 | 197 | ```elixir 198 | Mixpanel.start_client(Mixpanel.Config.client!(MyApp.Mixpanel.US, [project_token: "token"]) 199 | # => {:ok, #PID<0.123.0>} 200 | ``` 201 | 202 | `Mixpanel.Config.client!/2` makes sure that provided parameters are correct. 203 | 204 | And when you done with it, this function would stop the client immediately: 205 | 206 | ```elixir 207 | Mixpanel.terminate_client(MyApp.Mixpanel.US) 208 | # => :ok 209 | ``` 210 | 211 | To make it possible to call this client process you might want to use some macro 212 | magic, which would compile a new module in runtime: 213 | 214 | ```elixir 215 | ast = 216 | quote do 217 | use Mixpanel 218 | end 219 | 220 | Module.create(MyApp.Mixpanel.US, ast, Macro.Env.location(__ENV__)) 221 | # => {:module, _module, _bytecode, _exports} 222 | ``` 223 | 224 | If creating a module is not a case, you still can call the client's process 225 | directly (it's a GenServer after all). For instance: 226 | 227 | ```elixir 228 | Client.track(MyApp.Mixpanel.US, event, properties, opts) 229 | # => :ok 230 | ``` 231 | 232 | ## Usage 233 | 234 | ### Tracking events 235 | 236 | Use `Mixpanel.track/3` function to track events: 237 | 238 | ```elixir 239 | MyApp.Mixpanel.track( 240 | "Signed up", 241 | %{"Referred By" => "friend"}, 242 | distinct_id: "13793" 243 | ) 244 | # => :ok 245 | ``` 246 | 247 | The time an event occurred and IP address of an user can be provided via opts: 248 | 249 | ```elixir 250 | MyApp.Mixpanel.track( 251 | "Level Complete", 252 | %{"Level Number" => 9}, 253 | distinct_id: "13793", 254 | time: ~U[2013-01-15 00:00:00Z], 255 | ip: "203.0.113.9" 256 | ) 257 | # => :ok 258 | ``` 259 | 260 | ### Tracking profile updates 261 | 262 | Use `Mixpanel.engage/3,4` function to track profile updates: 263 | 264 | ```elixir 265 | MyApp.Mixpanel.engage( 266 | "13793", 267 | "$set", 268 | %{"Address" => "1313 Mockingbird Lane"} 269 | ) 270 | # => :ok 271 | ``` 272 | 273 | The time an event occurred and IP address of an user can be provided via opts: 274 | 275 | ```elixir 276 | MyApp.Mixpanel.engage( 277 | "13793", 278 | "$set", 279 | %{"Birthday" => "1948-01-01"}, 280 | time: ~U[2013-01-15 00:00:00Z], 281 | ip: "123.123.123.123" 282 | ) 283 | # => :ok 284 | ``` 285 | 286 | `Mixpanel.engage/2` works with batches: 287 | 288 | ```elixir 289 | MyApp.Mixpanel.engage( 290 | [ 291 | {"13793", "$set", %{"Address" => "1313 Mockingbird Lane"}}, 292 | {"13793", "$set", %{"Birthday" => "1948-01-01"}} 293 | ], 294 | ip: "123.123.123.123" 295 | ) 296 | # => :ok 297 | ``` 298 | 299 | ### Merging two profiles 300 | 301 | Use `Mixpanel.create_alias/2` create an alias for a district ID, effectively 302 | merging two profiles: 303 | 304 | ```elixir 305 | MyApp.Mixpanel.create_alias("13793", "13794") 306 | # => :ok 307 | ``` 308 | 309 | ## Telemetry 310 | 311 | `mixpanel_api_ex` uses Telemetry to provide instrumentation. See the 312 | `Mixpanel.Telemetry` module for details on specific events. 313 | 314 | ## Contributing 315 | 316 | 1. Fork it (https://github.com/asakura/mixpanel_api_ex/fork) 317 | 2. Create your feature branch (`git checkout -b my-new-feature`) 318 | 3. Test your changes by running unit tests and property based tests 319 | (`mix t && mix p`) 320 | 4. Check that provided changes does not have type errors (`mix dialyzer`) 321 | 5. Additionally you might run Gradient to have extra insight into type problems 322 | (`mix gradient`) 323 | 6. Make sure that code is formatted (`mix format`) 324 | 7. Run Credo to make sure that there is no code readability/maintainability 325 | issues (`mix credo --strict`) 326 | 8. Commit your changes (`git commit -am 'Add some feature'`) 327 | 9. Push to the branch (`git push origin my-new-feature`) 328 | 10. Create new Pull Request 329 | 330 | ## License 331 | 332 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 333 | 334 | Copyright (c) 2016-2023 [Mikalai Seva](https://github.com/asakura/) 335 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.2.4 -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config = "#{Mix.env()}.exs" 4 | 5 | File.exists?("config/#{config}") && import_config(config) 6 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :mixpanel_api_ex, MyApp.Mixpanel.US, 4 | base_url: "https://api.mixpanel.com", 5 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN"), 6 | http_adapter: Mixpanel.HTTP.HTTPC 7 | 8 | config :mixpanel_api_ex, MyApp.Mixpanel.EU, 9 | base_url: "https://api-eu.mixpanel.com", 10 | project_token: System.get_env("MIXPANEL_PROJECT_TOKEN"), 11 | http_adapter: Mixpanel.HTTP.Hackney 12 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :mixpanel_api_ex, MixpanelTest, 4 | project_token: "token", 5 | base_url: "https://api.mixpanel.com", 6 | http_adapter: MixpanelTest.HTTP.Mock 7 | 8 | config :mixpanel_api_ex, MixpanelTest.Using, 9 | project_token: "token", 10 | base_url: "https://api.mixpanel.com", 11 | http_adapter: MixpanelTest.HTTP.Mock 12 | -------------------------------------------------------------------------------- /lib/mixpanel.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel do 2 | @moduledoc """ 3 | Elixir client for the Mixpanel API. 4 | """ 5 | 6 | use Application 7 | 8 | alias Mixpanel.Client 9 | 10 | require Logger 11 | 12 | @doc false 13 | @doc export: true 14 | @spec __using__(any) :: Macro.t() 15 | defmacro __using__(_) do 16 | quote do 17 | @behaviour Mixpanel 18 | 19 | @spec track(Client.event(), Client.properties(), Mixpanel.track_options()) :: :ok 20 | def track(event, properties \\ %{}, opts \\ []), 21 | do: Client.track(unquote(__CALLER__.module), event, properties, opts) 22 | 23 | @spec engage([{Client.distinct_id(), String.t(), map}], Mixpanel.engage_options()) :: :ok 24 | def engage(batch, opts \\ []), 25 | do: Client.engage(unquote(__CALLER__.module), batch, opts) 26 | 27 | @spec engage(Client.distinct_id(), String.t(), map, Mixpanel.engage_options()) :: :ok 28 | def engage(distinct_id, operation, value, opts \\ []), 29 | do: Client.engage(unquote(__CALLER__.module), distinct_id, operation, value, opts) 30 | 31 | @spec create_alias(Client.alias_id(), Client.distinct_id()) :: :ok 32 | def create_alias(alias_id, distinct_id), 33 | do: Client.create_alias(unquote(__CALLER__.module), alias_id, distinct_id) 34 | end 35 | end 36 | 37 | @typedoc """ 38 | Possible common options to be passed to `Mixpanel.track/3` and `Mixpanel.engage/3`. 39 | 40 | * `:time` - The time an event occurred. If this property is not included in 41 | your request, Mixpanel will use the time the event arrives at the server. 42 | If present, the value should be one of: 43 | * `NaiveDateTime` struct (Etc/UTC timezone is assumed) 44 | * `DateTime` struct 45 | * a Unix timestamp (seconds since midnight, January 1st, 1970 - UTC) 46 | * an Erlang's `:erlang.timestamp()` tuple (`{mega_secs, secs, ms}`, 47 | microseconds are not supported) 48 | * an Erlang's `:calendar.datetime()` tuple (`{{yyyy, mm, dd}, {hh, mm, ss}}`) 49 | * `:ip` - An IP address string (e.g. "127.0.0.1") associated with the event. 50 | This is used for adding geolocation data to events, and should only be 51 | required if you are making requests from your backend. If `:ip` is absent, 52 | Mixpanel will ignore the IP address of the request. 53 | """ 54 | @type common_options :: [ 55 | time: 56 | DateTime.t() 57 | | NaiveDateTime.t() 58 | | :erlang.timestamp() 59 | | :calendar.datetime() 60 | | pos_integer(), 61 | ip: {1..255, 0..255, 0..255, 0..255} | String.t() 62 | ] 63 | 64 | @typedoc """ 65 | Possible options to be passed to `Mixpanel.track/3`. 66 | 67 | * `:distinct_id` - The value of distinct_id will be treated as a string, and 68 | used to uniquely identify a user associated with your event. If you provide 69 | a distinct_id with your events, you can track a given user through funnels 70 | and distinguish unique users for retention analyses. You should always send 71 | the same distinct_id when an event is triggered by the same user. 72 | """ 73 | @type track_options :: 74 | common_options 75 | | [ 76 | distinct_id: String.t() 77 | ] 78 | 79 | @typedoc """ 80 | Possible options to be passed to `Mixpanel.engage/3`. 81 | 82 | * `:ignore_time` - If the `:ignore_time` property is present and `true` in 83 | your update request, Mixpanel will not automatically update the "Last Seen" 84 | property of the profile. Otherwise, Mixpanel will add a "Last Seen" property 85 | associated with the current time for all $set, $append, and $add operations. 86 | """ 87 | @type engage_options :: 88 | common_options 89 | | [ 90 | ignore_time: boolean 91 | ] 92 | 93 | @impl Application 94 | @spec start(any, any) :: Supervisor.on_start() 95 | def start(_type, _args) do 96 | Mixpanel.Supervisor.start_link() 97 | end 98 | 99 | @impl Application 100 | @spec start_phase(:clients, :normal, any) :: :ok 101 | def start_phase(:clients, :normal, _phase_args) do 102 | for {client, config} <- Mixpanel.Config.clients!() do 103 | case Mixpanel.Supervisor.start_child(config) do 104 | {:ok, _pid} -> 105 | :ok 106 | 107 | {:ok, _pid, _info} -> 108 | :ok 109 | 110 | :ignore -> 111 | Logger.warning("#{client} was not started") 112 | 113 | {:error, reason} -> 114 | Logger.error("Could not start #{client} because of #{inspect(reason)}") 115 | end 116 | end 117 | 118 | :ok 119 | end 120 | 121 | @impl Application 122 | def prep_stop(state), do: state 123 | 124 | @impl Application 125 | def stop(_state), do: :ok 126 | 127 | @doc """ 128 | Dynamically starts a new client process. 129 | 130 | ## Examples 131 | 132 | iex> Mixpanel.start_client(Mixpanel.Config.client!(MyApp.Mixpanel.US, [project_token: "token"])) 133 | {:ok, #PID<0.123.0>} 134 | iex> Mixpanel.start_client(Mixpanel.Config.client!(MyApp.Mixpanel.US, [project_token: "token"])) 135 | {:error, {:already_started, #PID<0.298.0>}} 136 | """ 137 | @doc export: true 138 | @spec start_client(Mixpanel.Config.options()) :: DynamicSupervisor.on_start_child() 139 | defdelegate start_client(config), to: Mixpanel.Supervisor, as: :start_child 140 | 141 | @doc """ 142 | Stops a client process. 143 | 144 | ## Examples 145 | 146 | iex> Mixpanel.terminate_client(MyApp.Mixpanel.US) 147 | :ok 148 | iex> Mixpanel.terminate_client(MyApp.Mixpanel.US) 149 | {:error, :not_found} 150 | iex> Process.whereis(MyApp.Mixpanel.EU) 151 | nil 152 | """ 153 | @doc export: true 154 | @spec terminate_client(Mixpanel.Config.name()) :: :ok | {:error, :not_found} 155 | defdelegate terminate_client(client), to: Mixpanel.Supervisor, as: :terminate_child 156 | 157 | @doc """ 158 | Tracks an event. 159 | 160 | ## Arguments 161 | 162 | * `event` - A name for the event. 163 | * `properties` - A collection of properties associated with this event. 164 | * `opts` - See `t:track_options/0` for specific options to pass to this 165 | function. 166 | """ 167 | @callback track(Client.event(), Client.properties(), track_options) :: :ok 168 | 169 | @doc """ 170 | Same as `f:engage/4`, but accepts a list of `{distinct_id, operation, value}` 171 | tuples, then forms a batch request and send it the Ingestion API. 172 | 173 | ## Arguments 174 | 175 | * `batch` - See `f:engage/4` for details. 176 | * `opts` - See `t:engage_options/0` for specific options to pass to this 177 | function. 178 | """ 179 | @callback engage([{Client.distinct_id(), String.t(), map}], engage_options) :: :ok 180 | 181 | @doc """ 182 | Takes a `value` map argument containing names and values of profile 183 | properties. If the profile does not exist, it creates it with these 184 | properties. If it does exist, it sets the properties to these values, 185 | overwriting existing values. 186 | 187 | ## Arguments 188 | 189 | * `distinct_id` - This is a string that identifies the profile you would like 190 | to update. 191 | * `operation` - A name for the event. 192 | * `value`- A collection of properties associated with this event. 193 | * `opts` - See `t:engage_options/0` for specific options to pass to this 194 | function. 195 | """ 196 | @callback engage(Client.distinct_id(), String.t(), map, engage_options) :: :ok 197 | 198 | @doc """ 199 | Creates an alias for a distinct ID, merging two profiles. Mixpanel supports 200 | adding an alias to a distinct id. An alias is a new value that will be 201 | interpreted by Mixpanel as an existing value. That means that you can send 202 | messages to Mixpanel using the new value, and Mixpanel will continue to use 203 | the old value for calculating funnels and retention reports, or applying 204 | updates to user profiles. 205 | 206 | ## Arguments 207 | 208 | * `alias_id` - The new additional ID of the user. 209 | * `distinct_id` - The current ID of the user. 210 | 211 | """ 212 | @callback create_alias(Client.alias_id(), Client.distinct_id()) :: :ok 213 | end 214 | -------------------------------------------------------------------------------- /lib/mixpanel/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Client do 2 | use GenServer 3 | 4 | @moduledoc """ 5 | Mixpanel API Client GenServer. 6 | """ 7 | 8 | require Logger 9 | 10 | alias Mixpanel.Client.State 11 | alias Mixpanel.HTTP 12 | 13 | @type init_args :: [ 14 | Mixpanel.Config.option() | GenServer.option() | {Keyword.key(), Keyword.value()}, 15 | ... 16 | ] 17 | 18 | @type event :: String.t() | map 19 | @type properties :: map 20 | @type alias_id :: String.t() 21 | @type distinct_id :: String.t() 22 | 23 | @track_endpoint "/track" 24 | @engage_endpoint "/engage" 25 | @alias_endpoint "/track#identity-create-alias" 26 | @epoch :calendar.datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}}) 27 | 28 | @spec start_link(init_args) :: :ignore | {:error, any} | {:ok, pid} 29 | def start_link(init_args) do 30 | {gen_server_opts, opts} = 31 | Keyword.split(init_args, [:debug, :name, :timeout, :spawn_opt, :hibernate_after]) 32 | 33 | opts = 34 | opts 35 | |> Keyword.take([:project_token, :base_url, :http_adapter]) 36 | |> Keyword.put(:name, gen_server_opts[:name]) 37 | 38 | GenServer.start_link(__MODULE__, opts, gen_server_opts) 39 | end 40 | 41 | @spec child_spec(init_args) :: %{ 42 | id: __MODULE__, 43 | start: {__MODULE__, :start_link, [init_args, ...]} 44 | } 45 | def child_spec(init_args) do 46 | %{ 47 | id: __MODULE__, 48 | start: {__MODULE__, :start_link, [init_args]} 49 | } 50 | end 51 | 52 | @doc """ 53 | Tracks a event 54 | 55 | See `Mixpanel.track/3` 56 | """ 57 | @doc export: true 58 | @spec track(module, event, properties, Mixpanel.track_options()) :: :ok 59 | def track(server, event, properties, opts) do 60 | opts = validate_options(opts, [:distinct_id, :ip, :time], :opts) 61 | 62 | properties = 63 | properties 64 | |> Map.drop([:distinct_id, :ip, :time]) 65 | |> maybe_put(:time, to_timestamp(Keyword.get(opts, :time))) 66 | |> maybe_put(:distinct_id, Keyword.get(opts, :distinct_id)) 67 | |> maybe_put(:ip, convert_ip(Keyword.get(opts, :ip))) 68 | 69 | GenServer.cast(server, {:track, event, properties}) 70 | end 71 | 72 | @doc """ 73 | Updates a user profile. 74 | 75 | See `Mixpanel.engage/4`. 76 | """ 77 | @doc export: true 78 | @spec engage(module, [{distinct_id, String.t(), map}], Mixpanel.engage_options()) :: 79 | :ok 80 | def engage(server, [{_, _, _} | _] = batch, opts) do 81 | opts = validate_options(opts, [:ip, :time, :ignore_time], :opts) 82 | GenServer.cast(server, {:engage, Enum.map(batch, &build_engage_event(&1, opts))}) 83 | end 84 | 85 | @doc export: true 86 | @spec engage(module, distinct_id, String.t(), map, Mixpanel.engage_options()) :: :ok 87 | def engage(server, distinct_id, operation, value, opts) do 88 | opts = validate_options(opts, [:ip, :time, :ignore_time], :opts) 89 | GenServer.cast(server, {:engage, build_engage_event({distinct_id, operation, value}, opts)}) 90 | end 91 | 92 | @doc """ 93 | Creates an alias for a user profile. 94 | 95 | See `Mixpanel.create_alias/2`. 96 | """ 97 | @doc export: true 98 | @spec create_alias(module, alias_id, distinct_id) :: :ok 99 | def create_alias(server, alias_id, distinct_id) do 100 | GenServer.cast(server, {:create_alias, alias_id, distinct_id}) 101 | end 102 | 103 | @impl GenServer 104 | @spec init([Mixpanel.Config.option(), ...]) :: {:ok, State.t()} 105 | def init(opts) do 106 | Process.flag(:trap_exit, true) 107 | state = State.new(opts) 108 | 109 | client_span = 110 | Mixpanel.Telemetry.start_span(:client, %{}, %{ 111 | name: state.name, 112 | base_url: state.base_url, 113 | http_adapter: state.http_adapter 114 | }) 115 | 116 | {:ok, State.attach_span(state, client_span)} 117 | end 118 | 119 | @spec handle_cast( 120 | {:track, event, properties} 121 | | {:engage, event} 122 | | {:create_alias, alias_id, distinct_id}, 123 | State.t() 124 | ) :: {:noreply, State.t()} 125 | 126 | @impl GenServer 127 | def handle_cast({:track, event, properties}, state) do 128 | data = 129 | encode_params(%{ 130 | event: event, 131 | properties: Map.put_new(properties, :token, state.project_token) 132 | }) 133 | 134 | case HTTP.get(state.http_adapter, state.base_url <> @track_endpoint, [], params: [data: data]) do 135 | {:ok, _, _, _} -> 136 | Mixpanel.Telemetry.untimed_span_event( 137 | state.span, 138 | :send, 139 | %{ 140 | event: event 141 | # payload_size: byte_size(payload) 142 | }, 143 | %{name: state.name} 144 | ) 145 | 146 | :ok 147 | 148 | {:error, reason} -> 149 | Mixpanel.Telemetry.span_event( 150 | state.span, 151 | :send_error, 152 | %{ 153 | event: event, 154 | error: reason 155 | # payload_size: byte_size(payload) 156 | }, 157 | %{name: state.name} 158 | ) 159 | 160 | Logger.warning(%{message: "Problem tracking event", event: event, properties: properties}) 161 | end 162 | 163 | {:noreply, state} 164 | end 165 | 166 | @impl GenServer 167 | def handle_cast({:engage, event}, state) do 168 | data = 169 | event 170 | |> put_token(state.project_token) 171 | |> encode_params() 172 | 173 | case HTTP.get(state.http_adapter, state.base_url <> @engage_endpoint, [], 174 | params: [data: data] 175 | ) do 176 | {:ok, _, _, _} -> 177 | :ok 178 | 179 | {:error, _reason} -> 180 | Logger.warning(%{message: "Problem tracking profile update", event: event}) 181 | end 182 | 183 | {:noreply, state} 184 | end 185 | 186 | @impl GenServer 187 | def handle_cast({:create_alias, alias, distinct_id}, state) do 188 | data = 189 | %{ 190 | event: "$create_alias", 191 | properties: %{ 192 | token: state.project_token, 193 | alias: alias, 194 | distinct_id: distinct_id 195 | } 196 | } 197 | |> encode_params() 198 | 199 | case HTTP.post( 200 | state.http_adapter, 201 | state.base_url <> @alias_endpoint, 202 | "data=#{data}", 203 | [ 204 | {"Content-Type", "application/x-www-form-urlencoded"} 205 | ], 206 | [] 207 | ) do 208 | {:ok, _, _, _} -> 209 | :ok 210 | 211 | {:error, _} -> 212 | Logger.warning(%{ 213 | message: "Problem creating profile alias", 214 | alias: alias, 215 | distinct_id: distinct_id 216 | }) 217 | end 218 | 219 | {:noreply, state} 220 | end 221 | 222 | @impl GenServer 223 | @spec terminate(reason, State.t()) :: :ok 224 | when reason: :normal | :shutdown | {:shutdown, term} | term 225 | def terminate(_reason, state) do 226 | case state.span do 227 | span when not is_nil(span) -> 228 | Mixpanel.Telemetry.stop_span(span, %{}, %{name: state.name}) 229 | 230 | false -> 231 | :ok 232 | end 233 | end 234 | 235 | defp put_token(events, project_token) when is_list(events), 236 | do: Enum.map(events, &put_token(&1, project_token)) 237 | 238 | defp put_token(event, project_token), 239 | do: Map.put(event, :"$token", project_token) 240 | 241 | defp encode_params(params), 242 | do: Jason.encode!(params) |> :base64.encode() 243 | 244 | defp build_engage_event({distinct_id, operation, value}, opts) do 245 | %{"$distinct_id": distinct_id} 246 | |> Map.put(operation, value) 247 | |> maybe_put(:"$ip", convert_ip(Keyword.get(opts, :ip))) 248 | |> maybe_put(:"$time", to_timestamp(Keyword.get(opts, :time))) 249 | |> maybe_put(:"$ignore_time", Keyword.get(opts, :ignore_time, nil) == true) 250 | end 251 | 252 | @spec to_timestamp( 253 | nil 254 | | DateTime.t() 255 | | NaiveDateTime.t() 256 | | :erlang.timestamp() 257 | | :calendar.datetime() 258 | | pos_integer 259 | ) :: nil | integer 260 | defp to_timestamp(nil), do: nil 261 | 262 | defp to_timestamp(secs) when is_integer(secs), 263 | do: secs 264 | 265 | defp to_timestamp(%NaiveDateTime{} = dt), 266 | do: dt |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix() 267 | 268 | defp to_timestamp(%DateTime{} = dt), 269 | do: DateTime.to_unix(dt) 270 | 271 | defp to_timestamp({{_y, _mon, _d}, {_h, _m, _s}} = dt), 272 | do: 273 | dt 274 | |> :calendar.datetime_to_gregorian_seconds() 275 | |> Kernel.-(@epoch) 276 | 277 | defp to_timestamp({mega_secs, secs, _ms}), 278 | do: trunc(mega_secs * 1_000_000 + secs) 279 | 280 | @spec convert_ip(nil | {1..255, 1..255, 1..255, 1..255} | String.t()) :: nil | String.t() 281 | defp convert_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" 282 | defp convert_ip(ip) when is_binary(ip), do: ip 283 | defp convert_ip(nil), do: nil 284 | 285 | @dialyzer {:nowarn_function, maybe_put: 3} 286 | 287 | @spec maybe_put(map, any, any) :: map 288 | defp maybe_put(map, _key, nil), do: map 289 | defp maybe_put(map, key, value), do: Map.put(map, key, value) 290 | 291 | @dialyzer {:nowarn_function, validate_options: 3} 292 | 293 | @spec validate_options(Keyword.t(), [atom(), ...], String.t() | atom()) :: 294 | Keyword.t() | no_return() 295 | defp validate_options(options, valid_values, name) do 296 | case Keyword.split(options, valid_values) do 297 | {options, []} -> 298 | options 299 | 300 | {_, illegal_options} -> 301 | raise "Unsupported keys(s) in #{name}: #{inspect(Keyword.keys(illegal_options))}" 302 | end 303 | end 304 | end 305 | -------------------------------------------------------------------------------- /lib/mixpanel/client/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Client.State do 2 | @moduledoc false 3 | 4 | @type t :: %__MODULE__{ 5 | project_token: Mixpanel.Config.project_token(), 6 | base_url: Mixpanel.Config.base_url(), 7 | http_adapter: Mixpanel.Config.http_adapter(), 8 | name: Mixpanel.Config.name(), 9 | span: nil | Mixpanel.Telemetry.t() 10 | } 11 | 12 | @enforce_keys [:project_token, :base_url, :http_adapter, :name] 13 | defstruct @enforce_keys ++ [:span] 14 | 15 | @spec new(Mixpanel.Config.options()) :: t() 16 | def new(opts) do 17 | project_token = Keyword.fetch!(opts, :project_token) 18 | base_url = Keyword.fetch!(opts, :base_url) 19 | http_adapter = Keyword.fetch!(opts, :http_adapter) 20 | name = Keyword.fetch!(opts, :name) 21 | 22 | %__MODULE__{ 23 | project_token: project_token, 24 | base_url: base_url, 25 | http_adapter: http_adapter, 26 | name: name 27 | } 28 | end 29 | 30 | @spec attach_span(t(), Mixpanel.Telemetry.t()) :: t() 31 | def attach_span(state, span) do 32 | %__MODULE__{state | span: span} 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/mixpanel/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Config do 2 | @moduledoc """ 3 | Module that helps handling configuration values provided by a user. 4 | """ 5 | 6 | @type project_token :: String.t() 7 | @type base_url :: String.t() 8 | @type http_adapter :: Mixpanel.HTTP.HTTPC | Mixpanel.HTTP.Hackney | Mixpanel.HTTP.NoOp 9 | @type name :: atom 10 | 11 | @type option :: 12 | {:project_token, project_token} 13 | | {:base_url, base_url} 14 | | {:http_adapter, http_adapter} 15 | | {:name, name} 16 | 17 | @type options :: [option, ...] 18 | 19 | @base_url "https://api.mixpanel.com" 20 | @known_adapters [Mixpanel.HTTP.HTTPC, Mixpanel.HTTP.Hackney, Mixpanel.HTTP.NoOp] 21 | 22 | @doc false 23 | @spec clients!() :: [{name, options}] 24 | def clients!() do 25 | for {name, config} <- Application.get_all_env(:mixpanel_api_ex) do 26 | {name, client!(name, config)} 27 | end 28 | end 29 | 30 | @doc """ 31 | Helper that validates user provided configuration and substitutes default 32 | parameters when needed. Raises `ArgumentError` if the configuration is invalid. 33 | 34 | ## Examples 35 | 36 | iex> Mixpanel.Config.client!(MyApp.Mixpanel, [project_token: "token"]) 37 | [ 38 | http_adapter: Mixpanel.HTTP.HTTPC, 39 | base_url: "https://api.mixpanel.com", 40 | name: MyApp.Mixpanel, 41 | project_token: "token" 42 | ] 43 | """ 44 | @doc export: true 45 | @spec client!(name, options) :: options | no_return 46 | def client!(name, opts) when is_atom(name) and is_list(opts) do 47 | opts 48 | |> Keyword.put_new(:name, name) 49 | |> Keyword.put_new(:base_url, @base_url) 50 | |> Keyword.put_new(:http_adapter, Mixpanel.HTTP.HTTPC) 51 | |> validate_http_adapter!() 52 | |> Keyword.take([:name, :base_url, :http_adapter, :project_token]) 53 | end 54 | 55 | def client!(name, _) when not is_atom(name), 56 | do: raise(ArgumentError, "Expected a module name as a client name, got #{inspect(name)}") 57 | 58 | def client!(_, opts), 59 | do: raise(ArgumentError, "Expected a list of options, got #{inspect(opts)}") 60 | 61 | @doc """ 62 | Helper that validates user provided configuration and substitutes default 63 | parameters when needed. 64 | 65 | ## Examples 66 | 67 | iex> Mixpanel.Config.client(MyApp.Mixpanel, [project_token: "token"]) 68 | {:ok, 69 | [ 70 | http_adapter: Mixpanel.HTTP.HTTPC, 71 | base_url: "https://api.mixpanel.com", 72 | name: MyApp.Mixpanel, 73 | project_token: "token" 74 | ]} 75 | """ 76 | @doc export: true 77 | @spec client(name, options) :: {:ok, options} | {:error, String.t()} 78 | def client(name, opts) do 79 | {:ok, client!(name, opts)} 80 | rescue 81 | e in [ArgumentError] -> 82 | {:error, Exception.message(e)} 83 | end 84 | 85 | @spec validate_http_adapter!(options) :: options | no_return 86 | defp validate_http_adapter!(config) do 87 | case config[:http_adapter] do 88 | http_adapter when http_adapter in @known_adapters -> 89 | config 90 | 91 | http_adapter -> 92 | raise(ArgumentError, "Expected a valid http adapter, got #{inspect(http_adapter)}") 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/mixpanel/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.HTTP do 2 | @moduledoc """ 3 | Adapter specification for HTTP clients and API for accessing them. 4 | """ 5 | 6 | require Logger 7 | 8 | @max_retries 3 9 | 10 | @callback get(url :: String.t(), headers :: [{String.t(), binary}], opts :: keyword) :: 11 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 12 | | {:error, String.t()} 13 | 14 | @callback post( 15 | url :: String.t(), 16 | body :: binary, 17 | headers :: [{String.t(), binary}], 18 | opts :: keyword 19 | ) :: 20 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 21 | | {:error, String.t()} 22 | 23 | @spec get( 24 | client :: module, 25 | url :: String.t(), 26 | headers :: [{String.t(), binary}], 27 | opts :: keyword 28 | ) :: 29 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 30 | | {:error, String.t()} 31 | def get(client, url, headers, opts) do 32 | {params, opts} = Keyword.pop(opts, :params, nil) 33 | retry(url, fn -> client.get(build_url(url, params), headers, opts) end, @max_retries) 34 | end 35 | 36 | @spec post( 37 | client :: module, 38 | url :: String.t(), 39 | payload :: binary, 40 | headers :: [{String.t(), binary}], 41 | opts :: keyword 42 | ) :: 43 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 44 | | {:error, String.t()} 45 | def post(client, url, payload, headers, opts) do 46 | retry(url, fn -> client.post(url, payload, headers, opts) end, @max_retries) 47 | end 48 | 49 | @spec retry(String.t(), (-> {:ok, any, any, any} | {:error, String.t()}), non_neg_integer) :: 50 | {:ok, any, any, any} | {:error, String.t()} 51 | defp retry(url, fun, attempts_left) do 52 | case fun.() do 53 | {:ok, 200, _headers, "1"} = ok -> 54 | ok 55 | 56 | other -> 57 | attempts_left = attempts_left - 1 58 | 59 | reason = 60 | case other do 61 | {:ok, status, _headers, _body} -> 62 | Logger.warning(%{ 63 | message: "Retrying request", 64 | attempts_left: attempts_left, 65 | url: url, 66 | http_status: status 67 | }) 68 | 69 | "HTTP #{status}" 70 | 71 | {:error, reason} -> 72 | Logger.warning(%{ 73 | message: "Won't retry to request due to a client error", 74 | attempts_left: attempts_left, 75 | url: url, 76 | error: reason 77 | }) 78 | 79 | reason 80 | end 81 | 82 | if attempts_left > 0 do 83 | retry(url, fun, attempts_left) 84 | else 85 | {:error, reason} 86 | end 87 | end 88 | end 89 | 90 | defp build_url(url, nil), do: url 91 | defp build_url(url, data: data), do: "#{url}?#{URI.encode_query(data: data)}" 92 | end 93 | -------------------------------------------------------------------------------- /lib/mixpanel/http/hackney.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:hackney) do 2 | defmodule Mixpanel.HTTP.Hackney do 3 | @moduledoc """ 4 | Adapter for [hackney](https://github.com/benoitc/hackney). 5 | 6 | Remember to add `{:hackney, "~> 1.20"}` to dependencies (and `:hackney` to applications in `mix.exs`). 7 | 8 | ## Examples 9 | 10 | ``` 11 | # set globally in config/config.exs 12 | config :mixpanel_api_ex, :http_adapter, Mixpanel.HTTP.Hackney 13 | ``` 14 | 15 | ## Adapter specific options 16 | 17 | - `:max_body_length` - Max response body size in bytes. Actual response may 18 | be bigger because hackney stops after the last chunk that surpasses 19 | `:max_body_length`. Defaults to `:infinity`. 20 | """ 21 | 22 | @behaviour Mixpanel.HTTP 23 | 24 | @impl Mixpanel.HTTP 25 | @spec get(url :: String.t(), headers :: [{String.t(), binary}], opts :: keyword) :: 26 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 27 | | {:error, String.t()} 28 | def get(url, headers, opts) do 29 | request(:get, url, headers, "", opts) 30 | end 31 | 32 | @impl Mixpanel.HTTP 33 | @spec post( 34 | url :: String.t(), 35 | body :: binary, 36 | headers :: [{String.t(), binary}], 37 | opts :: keyword 38 | ) :: 39 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 40 | | {:error, String.t()} 41 | def post(url, body, headers, opts) do 42 | request(:post, url, headers, body, opts) 43 | end 44 | 45 | defp request(method, url, headers, body, opts) do 46 | opts = 47 | opts 48 | |> Keyword.split([:insecure]) 49 | |> then(fn {opts, _} -> opts end) 50 | |> Enum.reduce([], fn 51 | {:insecure, true}, acc -> 52 | [:insecure | acc] 53 | end) 54 | 55 | case :hackney.request(method, url, headers, body, opts) do 56 | {:ok, status_code, headers} -> 57 | {:ok, status_code, headers, <<>>} 58 | 59 | {:ok, status_code, headers, client} -> 60 | max_length = Keyword.get(opts, :max_body_length, :infinity) 61 | 62 | case :hackney.body(client, max_length) do 63 | {:ok, body} -> 64 | {:ok, status_code, headers, body} 65 | 66 | {:error, reason} -> 67 | {:error, to_string(reason)} 68 | end 69 | 70 | {:ok, {:maybe_redirect, _status_code, _headers, _client}} -> 71 | {:error, "Redirect not supported"} 72 | 73 | {:error, reason} -> 74 | {:error, inspect(reason)} 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/mixpanel/http/httpc.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:httpc) do 2 | defmodule Mixpanel.HTTP.HTTPC do 3 | @moduledoc """ 4 | Adapter for [httpc](http://erlang.org/doc/man/httpc.html). 5 | 6 | This is the default adapter. 7 | """ 8 | 9 | @behaviour Mixpanel.HTTP 10 | 11 | @impl Mixpanel.HTTP 12 | @spec get(url :: String.t(), headers :: [{String.t(), binary}], opts :: keyword) :: 13 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 14 | | {:error, String.t()} 15 | def get(url, headers, opts) do 16 | request(:get, url, headers, "", opts) 17 | end 18 | 19 | @impl Mixpanel.HTTP 20 | @spec post( 21 | url :: String.t(), 22 | body :: binary, 23 | headers :: [{String.t(), binary}], 24 | opts :: keyword 25 | ) :: 26 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 27 | | {:error, String.t()} 28 | def post(url, body, headers, opts) do 29 | request(:post, url, headers, body, opts) 30 | end 31 | 32 | defp request(method, url, headers, payload, opts) do 33 | content_type = 34 | case List.keyfind(headers, "Content-Type", 0) do 35 | {_, value} -> to_charlist(value) 36 | _ -> nil 37 | end 38 | 39 | http_opts = 40 | opts 41 | |> Keyword.split([:insecure]) 42 | |> then(fn {opts, _} -> opts end) 43 | |> Enum.reduce([], fn 44 | {:insecure, true}, acc -> 45 | [{:ssl, [{:verify, :verify_none}]} | acc] 46 | end) 47 | 48 | case do_request( 49 | method, 50 | # Erlang 22 comparability layer: httpc wants a charlist as URL 51 | # Remove it when OTP 22 support is dropped 52 | String.to_charlist(url), 53 | prepare_headers(headers), 54 | content_type, 55 | payload, 56 | [{:autoredirect, false} | http_opts] 57 | ) do 58 | {:ok, {{_, status_code, _}, headers, body}} -> 59 | {:ok, status_code, format_headers(headers), to_string(body)} 60 | 61 | {:error, reason} -> 62 | {:error, inspect(reason)} 63 | end 64 | end 65 | 66 | defp do_request(:get, url, headers, _content_type, _payload, http_opts) do 67 | :httpc.request(:get, {url, headers}, http_opts, []) 68 | end 69 | 70 | defp do_request(:post, url, headers, content_type, payload, http_opts) do 71 | :httpc.request(:post, {url, headers, content_type, payload}, http_opts, []) 72 | end 73 | 74 | defp format_headers(headers) do 75 | for {key, value} <- headers do 76 | {to_string(key), to_string(value)} 77 | end 78 | end 79 | 80 | defp prepare_headers(headers) do 81 | for {key, value} <- headers do 82 | {to_charlist(key), to_charlist(value)} 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/mixpanel/http/noop.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.HTTP.NoOp do 2 | @moduledoc """ 3 | A fake adapter which primary should be used for testing purposes. 4 | """ 5 | 6 | @behaviour Mixpanel.HTTP 7 | 8 | @impl Mixpanel.HTTP 9 | @spec get(url :: String.t(), headers :: [{String.t(), binary}], opts :: keyword) :: 10 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 11 | | {:error, String.t()} 12 | def get(_url, _headers, _opts) do 13 | {:ok, 200, [], "1"} 14 | end 15 | 16 | @impl Mixpanel.HTTP 17 | @spec post( 18 | url :: String.t(), 19 | body :: binary, 20 | headers :: [{String.t(), binary}], 21 | opts :: keyword 22 | ) :: 23 | {:ok, status :: 200..599, headers :: [{String.t(), binary}], body :: term} 24 | | {:error, String.t()} 25 | def post(_url, _body, _headers, _opts) do 26 | {:ok, 200, [], "1"} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mixpanel/queue.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Queue do 2 | @moduledoc """ 3 | A queue behaviour that allows to push elements under different prefixes 4 | and take them in the same order they were pushed. 5 | """ 6 | 7 | @doc ~S""" 8 | Creates a new queue with the given limit of stored elements. 9 | """ 10 | @callback new(pos_integer) :: any 11 | 12 | @doc ~S""" 13 | Pushes an element to the queue under `prefix` prefix. Returns `{:ok, queue}` 14 | if the element was added, or `:discarded` if the queue was full and the 15 | element was discarded. 16 | """ 17 | @callback push(any, any, any) :: {:ok, any} | :discarded 18 | 19 | @doc ~S""" 20 | Takes `amount` elements from the queue under `prefix` prefix. Returns a tuple 21 | with a list of elements and the updated queue. 22 | """ 23 | @callback take(any, any, non_neg_integer) :: {list, any} 24 | 25 | @doc ~S""" 26 | Returns the total number of elements in the queue. 27 | """ 28 | @callback length(any) :: non_neg_integer 29 | end 30 | -------------------------------------------------------------------------------- /lib/mixpanel/queue/simple.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Queue.Simple do 2 | @moduledoc """ 3 | A simple queue implementation that uses zipper technique and discards elements 4 | when it's full. 5 | """ 6 | @behaviour Mixpanel.Queue 7 | 8 | @type prefix :: atom 9 | @type zipper :: %{ 10 | length: non_neg_integer, 11 | head: list, 12 | tail: list 13 | } 14 | @type t :: %__MODULE__{ 15 | max_size: pos_integer, 16 | prefixes: %{required(prefix) => zipper} 17 | } 18 | 19 | defstruct max_size: 1, prefixes: %{default: %{length: 0, head: [], tail: []}} 20 | 21 | @dialyzer {:no_underspecs, new: 1} 22 | 23 | @impl Mixpanel.Queue 24 | @spec new(pos_integer) :: t 25 | def new(limit) when is_integer(limit) and limit > 0 do 26 | %__MODULE__{max_size: limit} 27 | end 28 | 29 | def new(limit), 30 | do: raise(ArgumentError, "limit must be greater than 0, got #{inspect(limit)}") 31 | 32 | @impl Mixpanel.Queue 33 | @spec push(t, prefix, any) :: {:ok, t} | :discarded 34 | def push(queue, prefix \\ :default, element) 35 | 36 | def push(%__MODULE__{max_size: max_size, prefixes: prefixes} = queue, prefix, element) do 37 | case __MODULE__.length(queue) >= max_size do 38 | false when is_map_key(prefixes, prefix) -> 39 | prefixes = 40 | queue.prefixes 41 | |> update_in([prefix, :length], &(&1 + 1)) 42 | |> update_in([prefix, :tail], &[element | &1]) 43 | 44 | {:ok, %__MODULE__{queue | prefixes: prefixes}} 45 | 46 | false -> 47 | {:ok, 48 | %__MODULE__{ 49 | queue 50 | | prefixes: 51 | Map.put( 52 | queue.prefixes, 53 | prefix, 54 | %{length: 1, head: [], tail: [element]} 55 | ) 56 | }} 57 | 58 | true -> 59 | :discarded 60 | end 61 | end 62 | 63 | @impl Mixpanel.Queue 64 | @spec take(t, prefix, non_neg_integer) :: {list, t} 65 | def take(queue, prefix \\ :default, amount) 66 | 67 | def take(%__MODULE__{prefixes: prefixes} = queue, prefix, amount) 68 | when is_map_key(prefixes, prefix) do 69 | case get_in(prefixes, [prefix, :tail]) do 70 | [] -> 71 | case Enum.split(get_in(prefixes, [prefix, :head]), amount) do 72 | {result, []} -> 73 | {result, %__MODULE__{queue | prefixes: Map.delete(prefixes, prefix)}} 74 | 75 | {result, new_head} -> 76 | prefixes = 77 | prefixes 78 | |> update_in([prefix, :length], &(&1 - amount)) 79 | |> update_in([prefix, :head], fn _ -> new_head end) 80 | 81 | {result, %__MODULE__{queue | prefixes: prefixes}} 82 | end 83 | 84 | tail -> 85 | prefixes = 86 | prefixes 87 | |> update_in([prefix, :head], &(&1 ++ Enum.reverse(tail))) 88 | |> update_in([prefix, :tail], fn _ -> [] end) 89 | 90 | take(%__MODULE__{queue | prefixes: prefixes}, prefix, amount) 91 | end 92 | end 93 | 94 | def take(%__MODULE__{} = queue, _prefix, _amount), do: {[], queue} 95 | 96 | @impl Mixpanel.Queue 97 | @spec length(t) :: non_neg_integer 98 | def length(%__MODULE__{prefixes: prefixes}) do 99 | for {_prefix, %{length: length}} <- prefixes, reduce: 0 do 100 | acc -> acc + length 101 | end 102 | end 103 | 104 | @spec length(t, prefix) :: non_neg_integer 105 | defp length(%{prefixes: prefixes}, prefix) do 106 | %{length: length} = prefixes[prefix] 107 | length 108 | end 109 | 110 | @spec elements(t, prefix) :: nonempty_maybe_improper_list 111 | defp elements(%{prefixes: prefixes}, prefix) do 112 | %{head: head, tail: tail} = prefixes[prefix] 113 | 114 | case tail do 115 | [] -> head 116 | _ -> head ++ Enum.reverse(tail) 117 | end 118 | end 119 | 120 | @doc ~S""" 121 | Returns an element at the given index, where index `0` is the head. 122 | Returns `:error` if index is out of bounds. 123 | """ 124 | @spec at(t, prefix, non_neg_integer) :: {:ok, any} | :error 125 | def at(%__MODULE__{prefixes: prefixes} = queue, prefix \\ :default, index) 126 | when is_map_key(prefixes, prefix) do 127 | if length(queue, prefix) > index do 128 | item = Enum.at(elements(queue, prefix), index) 129 | {:ok, item} 130 | else 131 | :error 132 | end 133 | end 134 | 135 | @doc ~S""" 136 | Returns an element the given index, where index `0` is the head. 137 | Raises `ArgumentError` if index is out of bounds. 138 | """ 139 | @spec at!(t, non_neg_integer) :: any 140 | def at!(%__MODULE__{} = queue, index) do 141 | case at(queue, index) do 142 | {:ok, item} -> item 143 | :error -> raise ArgumentError, "index #{index} out of bounds" 144 | end 145 | end 146 | 147 | @doc ~S""" 148 | Returns a list of all prefixes in the queue. 149 | """ 150 | @spec prefixes(t) :: [prefix] 151 | def prefixes(%__MODULE__{prefixes: prefixes}), do: Map.keys(prefixes) 152 | end 153 | 154 | defimpl Collectable, for: Mixpanel.Queue.Simple do 155 | @spec into(@for.t()) :: {@for.t(), (@for.t, {:cont, any} | :done | :halt -> @for.t() | :ok)} 156 | def into(orig) do 157 | {orig, 158 | fn 159 | queue, {:cont, {prefix, item}} -> 160 | case @for.push(queue, prefix, item) do 161 | {:ok, queue} -> queue 162 | :discarded -> queue 163 | end 164 | 165 | queue, {:cont, item} -> 166 | case @for.push(queue, :default, item) do 167 | {:ok, queue} -> queue 168 | :discarded -> queue 169 | end 170 | 171 | queue, :done -> 172 | queue 173 | 174 | _, :halt -> 175 | :ok 176 | end} 177 | end 178 | end 179 | 180 | defimpl Enumerable, for: Mixpanel.Queue.Simple do 181 | @spec count(@for.t) :: {:ok, non_neg_integer} 182 | def count(queue), 183 | do: {:ok, @for.length(queue)} 184 | 185 | @spec member?(@for.t, term) :: {:error, module} 186 | def member?(_queue, _item), 187 | do: {:error, __MODULE__} 188 | 189 | @spec reduce(@for.t, Enumerable.acc(), Enumerable.reducer()) :: Enumerable.result() 190 | def reduce(_queue, {:halt, acc}, _fun), 191 | do: {:halted, acc} 192 | 193 | def reduce(queue, {:suspend, acc}, fun), 194 | do: {:suspended, acc, &reduce(queue, &1, fun)} 195 | 196 | def reduce(queue, {:cont, acc}, fun) do 197 | case @for.length(queue) do 198 | 0 -> 199 | {:done, acc} 200 | 201 | _ -> 202 | [prefix | _] = @for.prefixes(queue) 203 | {[item], queue} = @for.take(queue, prefix, 1) 204 | reduce(queue, fun.({:default, item}, acc), fun) 205 | end 206 | end 207 | 208 | @spec slice(@for.t) :: 209 | {:ok, size :: non_neg_integer(), Enumerable.slicing_fun() | Enumerable.to_list_fun()} 210 | def slice(queue) do 211 | {:ok, @for.length(queue), 212 | fn 213 | _start, 0 -> 214 | [] 215 | 216 | start, len -> 217 | Enum.reduce((start + len - 1)..start, [], fn index, acc -> 218 | {:ok, item} = @for.at(queue, index) 219 | [item | acc] 220 | end) 221 | end} 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/mixpanel/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Supervisor do 2 | @moduledoc false 3 | 4 | use DynamicSupervisor 5 | 6 | @spec start_link() :: Supervisor.on_start() 7 | def start_link(), do: DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) 8 | 9 | @spec start_link(any) :: Supervisor.on_start() 10 | def start_link(_), do: start_link() 11 | 12 | @spec start_child(Mixpanel.Config.options()) :: DynamicSupervisor.on_start_child() 13 | def start_child(config), 14 | do: DynamicSupervisor.start_child(__MODULE__, {Mixpanel.Client, config}) 15 | 16 | @doc export: true 17 | @spec terminate_child(Mixpanel.Config.name()) :: :ok | {:error, :not_found} 18 | def terminate_child(client) do 19 | case Process.whereis(client) do 20 | pid when is_pid(pid) -> 21 | DynamicSupervisor.terminate_child(__MODULE__, pid) 22 | 23 | _ -> 24 | {:error, :not_found} 25 | end 26 | end 27 | 28 | @spec init(any) :: {:ok, DynamicSupervisor.sup_flags()} 29 | def init(_) do 30 | DynamicSupervisor.init(strategy: :one_for_one) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/mixpanel/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Telemetry do 2 | @moduledoc """ 3 | The following telemetry spans are emitted by mixpanel_api_ex: 4 | 5 | ## `[:mixpanel_api_ex, :client, *]` 6 | 7 | Represents a Mixpanel API client is ready 8 | 9 | This span is started by the following event: 10 | 11 | * `[:mixpanel_api_ex, :client, :start]` 12 | 13 | Represents the start of the span 14 | 15 | This event contains the following measurements: 16 | 17 | * `monotonic_time`: The time of this event, in `:native` units 18 | 19 | This event contains the following metadata: 20 | 21 | * `name`: The name of the client 22 | * `base_url`: The URL which a client instance uses to communicate with 23 | the Mixpanel API 24 | * `http_adapter`: The HTTP adapter which a client instance uses to send 25 | actual requests to the backend 26 | 27 | This span is ended by the following event: 28 | 29 | * `[:mixpanel_api_ex, :client, :stop]` 30 | 31 | Represents the end of the span 32 | 33 | This event contains the following measurements: 34 | 35 | * `monotonic_time`: The time of this event, in `:native` units 36 | * `duration`: The span duration, in `:native` units 37 | 38 | This event contains the following metadata: 39 | 40 | * `name`: The name of the client 41 | * `base_url`: The URL which a client instance uses to communicate with 42 | the Mixpanel API 43 | * `http_adapter`: The HTTP adapter which a client instance uses to send 44 | actual requests to the backend 45 | 46 | The following events may be emitted within this span: 47 | 48 | * `[:mixpanel_api_ex, :client, :send]` 49 | 50 | Represents a request sent to the Mixpanel API 51 | 52 | This event contains the following measurements: 53 | 54 | * `event`: The name of the event that was sent 55 | * `payload_size`: The size (in bytes) of the payload has been sent 56 | 57 | This event contains the following metadata: 58 | 59 | * `telemetry_span_context`: A unique identifier for this span 60 | * `name`: The name of the client 61 | 62 | * `[:mixpanel_api_ex, :client, :send_error]` 63 | 64 | An error occurred while sending a request to the Mixpanel API 65 | 66 | This event contains the following measurements: 67 | 68 | * `event`: The name of the event that was attempted to send 69 | * `error`: A description of the error 70 | * `payload_size`: The size (in bytes) of the payload that were attempted to send 71 | 72 | This event contains the following metadata: 73 | 74 | * `telemetry_span_context`: A unique identifier for this span 75 | * `name`: The name of the client 76 | """ 77 | 78 | @enforce_keys [:span_name, :telemetry_span_context, :start_time, :start_metadata] 79 | defstruct @enforce_keys 80 | 81 | @type t :: %__MODULE__{ 82 | span_name: span_name, 83 | telemetry_span_context: reference, 84 | start_time: integer, 85 | start_metadata: metadata 86 | } 87 | 88 | @type span_name :: :client 89 | @type metadata :: :telemetry.event_metadata() 90 | 91 | @typedoc false 92 | @type measurements :: :telemetry.event_measurements() 93 | 94 | @typedoc false 95 | @type event_name :: :ready | :send_error 96 | 97 | @typedoc false 98 | @type untimed_event_name :: :stop | :send 99 | 100 | @app_name :mixpanel_api_ex 101 | 102 | @doc false 103 | @spec start_span(span_name, measurements, metadata) :: t 104 | def start_span(span_name, measurements, metadata) do 105 | measurements = Map.put_new_lazy(measurements, :monotonic_time, &System.monotonic_time/0) 106 | telemetry_span_context = make_ref() 107 | metadata = Map.put(metadata, :telemetry_span_context, telemetry_span_context) 108 | _ = event([span_name, :start], measurements, metadata) 109 | 110 | %__MODULE__{ 111 | span_name: span_name, 112 | telemetry_span_context: telemetry_span_context, 113 | start_time: measurements[:monotonic_time], 114 | start_metadata: metadata 115 | } 116 | end 117 | 118 | @doc false 119 | @spec stop_span(t, measurements, metadata) :: :ok 120 | def stop_span(span, measurements \\ %{}, metadata \\ %{}) do 121 | measurements = Map.put_new_lazy(measurements, :monotonic_time, &System.monotonic_time/0) 122 | 123 | measurements = 124 | Map.put(measurements, :duration, measurements[:monotonic_time] - span.start_time) 125 | 126 | metadata = Map.merge(span.start_metadata, metadata) 127 | 128 | untimed_span_event(span, :stop, measurements, metadata) 129 | end 130 | 131 | @doc false 132 | @spec span_event(t, event_name, measurements, metadata) :: :ok 133 | def span_event(span, name, measurements \\ %{}, metadata \\ %{}) do 134 | measurements = Map.put_new_lazy(measurements, :monotonic_time, &System.monotonic_time/0) 135 | untimed_span_event(span, name, measurements, metadata) 136 | end 137 | 138 | @doc false 139 | @spec untimed_span_event(t, event_name | untimed_event_name, measurements, metadata) :: 140 | :ok 141 | def untimed_span_event(span, name, measurements \\ %{}, metadata \\ %{}) do 142 | metadata = Map.put(metadata, :telemetry_span_context, span.telemetry_span_context) 143 | event([span.span_name, name], measurements, metadata) 144 | end 145 | 146 | defp event(suffix, measurements, metadata) do 147 | :telemetry.execute([@app_name | suffix], measurements, metadata) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.Mixfile do 2 | use Mix.Project 3 | 4 | @external_resource "VERSION" 5 | @version File.read!(Path.join([__DIR__, "VERSION"])) 6 | 7 | def project do 8 | [ 9 | app: :mixpanel_api_ex, 10 | version: @version, 11 | elixir: "~> 1.12", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | compilers: compilers(Mix.env()), 16 | unused: unused(), 17 | deps: deps(), 18 | aliases: aliases(), 19 | preferred_cli_env: preferred_cli_env(), 20 | dialyzer: [ 21 | plt_core_path: "priv/plts", 22 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 23 | plt_add_deps: :apps_direct, 24 | plt_add_apps: [:logger, :inets], 25 | plt_ignore_apps: [:mix_unused, :propcheck], 26 | flags: [ 27 | "-Werror_handling", 28 | "-Wextra_return", 29 | "-Wmissing_return", 30 | "-Wunknown", 31 | "-Wunmatched_returns", 32 | "-Wunderspecs" 33 | ] 34 | ], 35 | propcheck: [counter_examples: "propcheck_counter_examples"], 36 | test_paths: test_paths(Mix.env()), 37 | test_coverage: [ 38 | tool: ExCoveralls, 39 | export: "cov", 40 | test_task: "test", 41 | summary: [ 42 | threshold: 80 43 | ] 44 | ], 45 | 46 | # Hex 47 | description: description(), 48 | package: package(), 49 | 50 | # Docs 51 | name: "Mixpanel API", 52 | docs: [ 53 | extras: ["README.md", "CHANGELOG.md"], 54 | source_ref: "v#{@version}", 55 | main: "Mixpanel", 56 | source_url: "https://github.com/asakura/mixpanel_api_ex" 57 | ] 58 | ] 59 | end 60 | 61 | def description do 62 | "Elixir client for the Mixpanel API." 63 | end 64 | 65 | defp elixirc_paths(:test), do: ["test/support", "lib"] 66 | defp elixirc_paths(:property), do: ["property/support", "lib"] 67 | defp elixirc_paths(_), do: ["lib"] 68 | 69 | defp test_paths(:property), do: ["property"] 70 | defp test_paths(_), do: ["test"] 71 | 72 | def package do 73 | [ 74 | maintainers: ["Mikalai Seva"], 75 | licenses: ["MIT"], 76 | links: %{"GitHub" => "https://github.com/asakura/mixpanel_api_ex"}, 77 | files: ~w(mix.exs README.md CHANGELOG.md lib VERSION) 78 | ] 79 | end 80 | 81 | def application() do 82 | [ 83 | mod: {Mixpanel, []}, 84 | extra_applications: [:logger], 85 | start_phases: [ 86 | clients: [] 87 | ] 88 | ] 89 | end 90 | 91 | defp compilers(:dev) do 92 | [:unused] ++ Mix.compilers() 93 | end 94 | 95 | defp compilers(_), do: Mix.compilers() 96 | 97 | defp deps do 98 | [ 99 | {:bandit, "~> 1.0", only: :test}, 100 | # only to make excoveralls to work on OTP 24 101 | {:castore, "~> 1.0", only: :test}, 102 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 103 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, 104 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 105 | {:excoveralls, "~> 0.18", only: [:test, :property]}, 106 | # Erlang 22 comparability layer 107 | # Remove it when OTP 22 support is dropped 108 | unquote_splicing( 109 | if :erlang.system_info(:otp_release) |> to_string() == "22" do 110 | [] 111 | else 112 | quote do 113 | [ 114 | {:gradient, github: "esl/gradient", ref: "33e13fb", only: [:dev], runtime: false} 115 | # {:gradient_macros, github: "esl/gradient_macros", ref: "3bce214", runtime: false}, 116 | ] 117 | end 118 | end 119 | ), 120 | {:hackney, "~> 1.20", only: [:test, :dev]}, 121 | {:jason, "~> 1.4"}, 122 | {:machete, "~> 0.2", only: :test}, 123 | {:mix_unused, "~> 0.4.1", only: :dev}, 124 | {:mox, "~> 1.1", only: :test}, 125 | {:propcheck, "~> 1.4", only: [:property, :dev]}, 126 | {:telemetry, "~> 0.4 or ~> 1.0"} 127 | ] 128 | end 129 | 130 | defp preferred_cli_env do 131 | [ 132 | c: :dev, 133 | t: :test, 134 | ti: :test, 135 | p: :property, 136 | "test.property": :property, 137 | coveralls: :test, 138 | "coveralls.github": :test, 139 | "coveralls.detail": :test, 140 | "coveralls.post": :test, 141 | "coveralls.html": :test, 142 | "coveralls.cobertura": :test 143 | ] 144 | end 145 | 146 | defp aliases() do 147 | [ 148 | c: "compile", 149 | t: "test --no-start", 150 | p: &run_property_tests/1, 151 | d: "dialyzer", 152 | g: "gradient", 153 | test: "test --no-start", 154 | "test.property": &run_property_tests/1 155 | ] 156 | end 157 | 158 | defp unused() do 159 | [ 160 | ignore: [ 161 | {:_, :child_spec, :_}, 162 | {:_, :start_link, :_} 163 | ] 164 | ] 165 | end 166 | 167 | defp run_property_tests(args) do 168 | env = Mix.env() 169 | args = if IO.ANSI.enabled?(), do: ["--color" | args], else: ["--no-color" | args] 170 | IO.puts("Running tests with `MIX_ENV=#{env}`") 171 | 172 | {_, res} = 173 | System.cmd("mix", ["test" | args], 174 | into: IO.binstream(:stdio, :line), 175 | env: [{"MIX_ENV", to_string(env)}] 176 | ) 177 | 178 | if res > 0 do 179 | System.at_exit(fn _ -> exit({:shutdown, 1}) end) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.1.0", "1414e65916229d4ee0914f6d4e7f8ec16c6f2d90e01ad5174d89e90baa577625", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4891fb2f48a83445da70a4e949f649a9b4032310f1f640f4a8a372bc91cece18"}, 3 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 4 | "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, 5 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 6 | "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [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", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [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", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 13 | "gradient": {:git, "https://github.com/esl/gradient.git", "33e13fbe1ff60a49abdc638d76b77effddf3cc45", [ref: "33e13fb"]}, 14 | "gradient_macros": {:git, "https://github.com/esl/gradient_macros.git", "3bce2146bf0cdf380f773c40e2b7bd6558ab6de8", [ref: "3bce214"]}, 15 | "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "3021d29d82741399d131e3be38d2a8db79d146d4", [tag: "0.3.0"]}, 16 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.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", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 17 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 18 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 19 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 20 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 21 | "machete": {:hex, :machete, "0.2.8", "ca7be4d2d65f05f4ab5eac0f5fd339cea08d4d75f775ac6c22991df2baed5d80", [:mix], [], "hexpm", "09bf5b306385d01c877453ead94914a595beecba3f2525c7364ee3ba0630a224"}, 22 | "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"}, 23 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 24 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 25 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 26 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 27 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 28 | "mix_unused": {:hex, :mix_unused, "0.4.1", "9f8d759a300a79d2077d6baf617f3a5af6935d50b0f113c09295b265afc3e411", [:mix], [{:libgraph, ">= 0.0.0", [hex: :libgraph, repo: "hexpm", optional: false]}], "hexpm", "fa21f688a88e0710e3d96ac1c8e5a6181aea8a75c8a4214f0edcfeb069b831a3"}, 29 | "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, 30 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 31 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 32 | "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, 33 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 34 | "propcheck": {:hex, :propcheck, "1.4.1", "c12908dbe6f572032928548089b34ff9d40672d5d70f1562e3a9e9058d226cc9", [:mix], [{:libgraph, "~> 0.13", [hex: :libgraph, repo: "hexpm", optional: false]}, {:proper, "~> 1.4", [hex: :proper, repo: "hexpm", optional: false]}], "hexpm", "e1b088f574785c3c7e864da16f39082d5599b3aaf89086d3f9be6adb54464b19"}, 35 | "proper": {:hex, :proper, "1.4.0", "89a44b8c39d28bb9b4be8e4d715d534905b325470f2e0ec5e004d12484a79434", [:rebar3], [], "hexpm", "18285842185bd33efbda97d134a5cb5a0884384db36119fee0e3cfa488568cbb"}, 36 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 37 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 38 | "thousand_island": {:hex, :thousand_island, "1.1.0", "dcc115650adc61c5e7de12619f0cb94b2b8f050326e7f21ffbf6fdeb3d291e4c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7745cf71520d74e119827ff32c2da6307e822cf835bebed3b2c459cc57f32d21"}, 39 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 40 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 41 | } 42 | -------------------------------------------------------------------------------- /property/mixpanel/queue/simple_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest.Queue.SimpleTest do 2 | use ExUnit.Case, async: true 3 | # , default_opts: [numtests: 500] 4 | use PropCheck 5 | 6 | alias Mixpanel.Queue.Simple 7 | 8 | property "a queue always has correct count" do 9 | forall {_limit, model, queue} <- queue() do 10 | assert Simple.length(queue) == model_length(model) 11 | end 12 | end 13 | 14 | property "a queue never overflows" do 15 | forall {limit, model, queue} <- queue() do 16 | length = Simple.length(queue) 17 | assert length >= 0 18 | assert length <= limit 19 | assert length == model_length(model) 20 | end 21 | end 22 | 23 | property "a queue always returns everything added" do 24 | forall {limit, model, queue} <- queue() do 25 | {_, queue} = 26 | for prefix <- Map.keys(model), reduce: {model, queue} do 27 | {model, queue} -> 28 | {entire_prefix, model} = Map.pop(model, prefix) 29 | assert {^entire_prefix, queue} = Simple.take(queue, prefix, limit) 30 | {model, queue} 31 | end 32 | 33 | assert Simple.length(queue) == 0 34 | end 35 | end 36 | 37 | property "queue always returns correct number of elements" do 38 | forall [{limit, model, orig_queue} <- queue(), batch_size <- limit()] do 39 | {queue, _, elements} = 40 | for prefix <- Map.keys(model), reduce: {orig_queue, model, []} do 41 | {queue, model, elements} -> 42 | take_all(queue, model, prefix, limit, batch_size, elements) 43 | end 44 | 45 | assert Simple.length(queue) == 0 46 | 47 | assert elements == 48 | Enum.reduce(orig_queue, [], fn {_prefix, value}, acc -> acc ++ [value] end) 49 | end 50 | end 51 | 52 | defp prefix(), do: oneof([range(1, 10), integer()]) 53 | defp val(), do: binary() 54 | defp limit(), do: integer(1, :inf) 55 | 56 | defp queue() do 57 | let [limit <- limit(), vicinity <- list({prefix(), val()})] do 58 | queue = Enum.into(vicinity, Simple.new(limit)) 59 | model = model_queue(vicinity, limit) 60 | 61 | {limit, model, queue} 62 | end 63 | end 64 | 65 | defp model_queue(vicinity, limit) do 66 | {_, model} = 67 | Enum.reduce_while( 68 | vicinity, 69 | {0, %{}}, 70 | fn 71 | {prefix, value}, {count, acc} when count < limit -> 72 | count = count + 1 73 | acc = Map.update(acc, prefix, [value], &[value | &1]) 74 | {:cont, {count, acc}} 75 | 76 | _, acc -> 77 | {:halt, acc} 78 | end 79 | ) 80 | 81 | for {prefix, values} <- model, into: %{} do 82 | {prefix, Enum.reverse(values)} 83 | end 84 | end 85 | 86 | defp model_length(model) do 87 | Map.values(model) |> List.flatten() |> Kernel.length() 88 | end 89 | 90 | defp take_all(queue, model, prefix, limit, batch_size, elements \\ []) do 91 | model_batch = Enum.take(model[prefix], min(batch_size, limit)) 92 | model = Map.put(model, prefix, Enum.drop(model[prefix], length(model_batch))) 93 | 94 | assert {^model_batch, queue} = Simple.take(queue, prefix, batch_size) 95 | assert Simple.length(queue) == model_length(model) 96 | 97 | elements = elements ++ model_batch 98 | 99 | case length(model[prefix]) do 100 | 0 -> {queue, model, elements} 101 | _ -> take_all(queue, model, prefix, limit, batch_size, elements) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /property/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:propcheck) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /test/http_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest.HTTPTest do 2 | use ExUnit.Case 3 | use Machete 4 | 5 | alias Mixpanel.HTTP.{Hackney, HTTPC, NoOp} 6 | 7 | @base_url "https://localhost:40010" 8 | 9 | setup_all do 10 | child = 11 | { 12 | Bandit, 13 | plug: MixpanelTest.Plug, 14 | scheme: :https, 15 | ip: :loopback, 16 | port: 40_010, 17 | cipher_suite: :strong, 18 | otp_app: :mixpanel_api_ex, 19 | certfile: Path.join(__DIR__, "support/selfsigned.pem"), 20 | keyfile: Path.join(__DIR__, "support/selfsigned_key.pem") 21 | } 22 | 23 | start_supervised!(child) 24 | 25 | :ok 26 | end 27 | 28 | describe "NoOp adapter" do 29 | test "get/3" do 30 | response = NoOp.get("#{@base_url}/get_endpoint", [], insecure: true) 31 | 32 | assert response == {:ok, 200, [], "1"} 33 | end 34 | 35 | test "post/4" do 36 | response = 37 | NoOp.post( 38 | "#{@base_url}/post_endpoint", 39 | "body", 40 | [ 41 | {"Content-Type", "application/x-www-form-urlencoded"} 42 | ], 43 | insecure: true 44 | ) 45 | 46 | assert response == {:ok, 200, [], "1"} 47 | end 48 | end 49 | 50 | describe "HTTP adapters" do 51 | for adapter <- [Hackney, HTTPC] do 52 | test "#{adapter}.get/3" do 53 | case unquote(adapter).get("#{@base_url}/get_endpoint", [], insecure: true) do 54 | {:ok, 200, _headers, body} -> 55 | assert Jason.decode!(body) 56 | ~> %{ 57 | "body" => "", 58 | "method" => "GET", 59 | "query_params" => map(size: 0), 60 | "request_headers" => 61 | list(min: 1, elements: list(min: 2, max: 2, elements: string())), 62 | "request_path" => "/get_endpoint" 63 | } 64 | 65 | {:ok, status, _headers, _body} -> 66 | refute "Expected 200, got #{status}" 67 | 68 | {:error, error} -> 69 | refute "Expected response, got #{inspect(error)}" 70 | end 71 | end 72 | 73 | test "#{adapter}.post/4" do 74 | case unquote(adapter).post( 75 | "#{@base_url}/post_endpoint", 76 | "body", 77 | [ 78 | {"Content-Type", "application/x-www-form-urlencoded"} 79 | ], 80 | insecure: true 81 | ) do 82 | {:ok, 200, _headers, body} -> 83 | assert Jason.decode!(body) 84 | ~> %{ 85 | "body" => "body", 86 | "method" => "POST", 87 | "query_params" => map(size: 0), 88 | "request_headers" => 89 | list(min: 1, elements: list(min: 2, max: 2, elements: string())), 90 | "request_path" => "/post_endpoint" 91 | } 92 | 93 | {:ok, status, _headers, _body} -> 94 | refute "Expected 200, got #{status}" 95 | 96 | {:error, error} -> 97 | refute "Expected 200, got #{inspect(error)}" 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/mixpanel/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest.ClientTest do 2 | use ExUnit.Case, async: false 3 | use Machete 4 | 5 | describe "init/1" do 6 | test "emits expected telemetry event" do 7 | {:ok, collector_pid} = 8 | start_supervised({Mixpanel.TelemetryCollector, [[:mixpanel_api_ex, :client, :start]]}) 9 | 10 | {:ok, %Mixpanel.Client.State{} = state} = 11 | Mixpanel.Client.init( 12 | project_token: "token", 13 | base_url: "base url", 14 | http_adapter: Mixpanel.HTTP.NoOp, 15 | name: make_ref() 16 | ) 17 | 18 | # We expect a monotonic start time as a measurement in the event. 19 | assert Mixpanel.TelemetryCollector.get_events(collector_pid) 20 | ~> [ 21 | {[:mixpanel_api_ex, :client, :start], %{monotonic_time: integer()}, 22 | %{ 23 | telemetry_span_context: reference(), 24 | name: state.name, 25 | base_url: "base url", 26 | http_adapter: Mixpanel.HTTP.NoOp 27 | }} 28 | ] 29 | end 30 | end 31 | 32 | describe "terminate/2" do 33 | test "emits telemetry event with expected timings" do 34 | {:ok, %Mixpanel.Client.State{} = state} = 35 | Mixpanel.Client.init( 36 | project_token: "token", 37 | base_url: "base url", 38 | http_adapter: Mixpanel.HTTP.NoOp, 39 | name: make_ref() 40 | ) 41 | 42 | {:ok, collector_pid} = 43 | start_supervised({Mixpanel.TelemetryCollector, [[:mixpanel_api_ex, :client, :stop]]}) 44 | 45 | Mixpanel.Client.terminate(:normal, state) 46 | 47 | # We expect a monotonic start time as a measurement in the event. 48 | assert [ 49 | {[:mixpanel_api_ex, :client, :stop], 50 | %{monotonic_time: stop_monotonic_time, duration: duration}, stop_metadata} 51 | ] = Mixpanel.TelemetryCollector.get_events(collector_pid) 52 | 53 | assert is_integer(stop_monotonic_time) 54 | 55 | # We expect the duration to be the monotonic stop # time minus the monotonic start time. 56 | assert stop_monotonic_time >= state.span.start_time 57 | assert duration == stop_monotonic_time - state.span.start_time 58 | 59 | # The start and stop metadata should be equal. 60 | assert stop_metadata == state.span.start_metadata 61 | end 62 | end 63 | 64 | describe "handle_cast/2" do 65 | setup do 66 | {:ok, state} = 67 | Mixpanel.Client.init( 68 | project_token: "token", 69 | base_url: "base url", 70 | http_adapter: Mixpanel.HTTP.NoOp, 71 | name: make_ref() 72 | ) 73 | 74 | {:ok, state: state} 75 | end 76 | 77 | test "emits telemetry event with expected timings", %{state: state} do 78 | {:ok, collector_pid} = 79 | start_supervised({Mixpanel.TelemetryCollector, [[:mixpanel_api_ex, :client, :send]]}) 80 | 81 | {:noreply, %Mixpanel.Client.State{}} = 82 | Mixpanel.Client.handle_cast({:track, "event", %{}}, state) 83 | 84 | assert Mixpanel.TelemetryCollector.get_events(collector_pid) 85 | ~> [ 86 | {[:mixpanel_api_ex, :client, :send], %{event: "event"}, 87 | %{ 88 | telemetry_span_context: reference(), 89 | name: state.name 90 | }} 91 | ] 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/mixpanel/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.ConfigTest do 2 | use ExUnit.Case, async: true 3 | use Machete 4 | 5 | alias Mixpanel.Config 6 | 7 | test "client/2 put default options" do 8 | assert Config.client!(MyApp.Mixpanel, project_token: "token") 9 | ~> in_any_order([ 10 | {:name, MyApp.Mixpanel}, 11 | {:base_url, "https://api.mixpanel.com"}, 12 | {:http_adapter, Mixpanel.HTTP.HTTPC}, 13 | {:project_token, "token"} 14 | ]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/mixpanel/supervisor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.SupervisorTest do 2 | use ExUnit.Case 3 | 4 | alias Mixpanel.Config 5 | 6 | test "start_link/0 with no clients configured" do 7 | assert {:ok, pid} = Mixpanel.Supervisor.start_link() 8 | assert :ok = DynamicSupervisor.stop(pid) 9 | refute Process.alive?(pid) 10 | end 11 | 12 | describe "dynamic children" do 13 | setup do 14 | start_supervised!(Mixpanel.Supervisor) 15 | :ok 16 | end 17 | 18 | test "start_child/1 starts a client process" do 19 | assert {:ok, pid} = 20 | Mixpanel.Supervisor.start_child( 21 | Config.client!(__MODULE__.Mixpanel, project_token: "") 22 | ) 23 | 24 | assert Process.alive?(pid) 25 | 26 | assert {:error, {:already_started, ^pid}} = 27 | Mixpanel.Supervisor.start_child( 28 | Config.client!(__MODULE__.Mixpanel, project_token: "") 29 | ) 30 | 31 | assert Process.alive?(pid) 32 | end 33 | 34 | test "terminate_child/1 kills a client process" do 35 | Mixpanel.Supervisor.start_child(Config.client!(__MODULE__.Mixpanel.A, project_token: "")) 36 | Mixpanel.Supervisor.start_child(Config.client!(__MODULE__.Mixpanel.B, project_token: "")) 37 | 38 | assert :ok = Mixpanel.Supervisor.terminate_child(__MODULE__.Mixpanel.A) 39 | 40 | refute Process.whereis(__MODULE__.Mixpanel.A) 41 | assert Process.whereis(__MODULE__.Mixpanel.B) 42 | 43 | assert :ok = Mixpanel.Supervisor.terminate_child(__MODULE__.Mixpanel.B) 44 | 45 | refute Process.whereis(__MODULE__.Mixpanel.A) 46 | refute Process.whereis(__MODULE__.Mixpanel.B) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/mixpanel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest.Test do 2 | use ExUnit.Case 3 | use Machete 4 | 5 | import ExUnit.CaptureLog 6 | import Mox 7 | 8 | setup :verify_on_exit! 9 | 10 | setup do 11 | pid = 12 | start_supervised!( 13 | {Mixpanel.Client, 14 | [ 15 | base_url: "http://localhost:4000", 16 | http_adapter: MixpanelTest.HTTP.Mock, 17 | name: MixpanelTest, 18 | project_token: "" 19 | ]} 20 | ) 21 | 22 | MixpanelTest.HTTP.Mock 23 | |> allow(self(), pid) 24 | 25 | {:ok, client: pid} 26 | end 27 | 28 | test "retries when HTTP client returns error" do 29 | MixpanelTest.HTTP.Mock 30 | |> expect(:get, 3, fn _url, _headers, _opts -> 31 | {:error, ""} 32 | end) 33 | 34 | capture_log(fn -> 35 | MixpanelTest.track("Signed up", %{"Referred By" => "friend"}, distinct_id: "13793") 36 | :timer.sleep(50) 37 | end) 38 | end 39 | 40 | test "retries when API asks to retry later" do 41 | MixpanelTest.HTTP.Mock 42 | |> expect(:get, 3, fn _url, _headers, _opts -> {:ok, 503, [], ""} end) 43 | 44 | capture_log(fn -> 45 | MixpanelTest.track("Signed up", %{"Referred By" => "friend"}, distinct_id: "13793") 46 | :timer.sleep(50) 47 | end) 48 | end 49 | 50 | describe "tracks an event" do 51 | setup do 52 | MixpanelTest.HTTP.Mock 53 | |> expect(:get, fn url, _headers, _opts -> 54 | uri = parse(url) 55 | 56 | assert uri.path == "/track" 57 | assert uri.query ~> string(starts_with: "data=") 58 | 59 | {:ok, 200, [], "1"} 60 | end) 61 | 62 | :ok 63 | end 64 | 65 | test "track/1" do 66 | MixpanelTest.track("Signed up") 67 | :timer.sleep(50) 68 | end 69 | 70 | test "track/2" do 71 | MixpanelTest.track("Signed up", %{"Referred By" => "friend"}) 72 | :timer.sleep(50) 73 | end 74 | 75 | test "track/3" do 76 | MixpanelTest.track("Signed up", %{"Referred By" => "friend"}, distinct_id: "13793") 77 | :timer.sleep(50) 78 | end 79 | 80 | test "track/3 with IP string" do 81 | MixpanelTest.track("Level Complete", %{"Level Number" => 9}, 82 | distinct_id: "13793", 83 | ip: "203.0.113.9" 84 | ) 85 | 86 | :timer.sleep(50) 87 | end 88 | 89 | test "track/3 with IP tuple" do 90 | MixpanelTest.track("Level Complete", %{"Level Number" => 9}, 91 | distinct_id: "13793", 92 | ip: {203, 0, 113, 9} 93 | ) 94 | 95 | :timer.sleep(50) 96 | end 97 | end 98 | 99 | describe "tracks an event with time" do 100 | setup do 101 | MixpanelTest.HTTP.Mock 102 | |> expect(:get, fn url, _headers, _opts -> 103 | uri = parse(url) 104 | 105 | assert uri.path == "/track" 106 | assert uri.query ~> string(starts_with: "data=") 107 | 108 | data = 109 | uri.query 110 | |> URI.decode_query() 111 | |> then(& &1["data"]) 112 | |> :base64.decode() 113 | |> Jason.decode!() 114 | 115 | assert data 116 | ~> %{ 117 | "event" => string(), 118 | "properties" => %{ 119 | "time" => 1_358_208_000, 120 | "token" => string() 121 | } 122 | } 123 | 124 | {:ok, 200, [], "1"} 125 | end) 126 | 127 | :ok 128 | end 129 | 130 | test "track/3 handles NaiveDatetime" do 131 | MixpanelTest.track("Level Complete", %{}, time: ~N[2013-01-15 00:00:00]) 132 | :timer.sleep(50) 133 | end 134 | 135 | test "track/3 handles Datetime" do 136 | MixpanelTest.track("Level Complete", %{}, time: ~U[2013-01-15 00:00:00Z]) 137 | :timer.sleep(50) 138 | end 139 | 140 | test "track/3 handles Unix timestamps" do 141 | MixpanelTest.track("Level Complete", %{}, time: 1_358_208_000) 142 | :timer.sleep(50) 143 | end 144 | 145 | test "track/3 handles Erlang calendar timestamps" do 146 | MixpanelTest.track("Level Complete", %{}, time: {{2013, 01, 15}, {00, 00, 00}}) 147 | :timer.sleep(50) 148 | end 149 | 150 | test "track/3 handles Erlang timestamps" do 151 | MixpanelTest.track("Level Complete", %{}, time: {1358, 208_000, 0}) 152 | :timer.sleep(50) 153 | end 154 | end 155 | 156 | describe "tracks a profile update" do 157 | setup do 158 | MixpanelTest.HTTP.Mock 159 | |> expect(:get, fn url, _headers, _opts -> 160 | uri = parse(url) 161 | 162 | assert uri.path == "/engage" 163 | assert uri.query ~> string(starts_with: "data=") 164 | 165 | {:ok, 200, [], "1"} 166 | end) 167 | 168 | :ok 169 | end 170 | 171 | # test "engage/2" do 172 | # MixpanelTest.engage("13793", "$set") 173 | # :timer.sleep(50) 174 | # end 175 | 176 | test "engage/3" do 177 | MixpanelTest.engage("13793", "$set", %{"Address" => "1313 Mockingbird Lane"}) 178 | :timer.sleep(50) 179 | end 180 | 181 | test "engage/4 with IP string" do 182 | MixpanelTest.engage( 183 | "13793", 184 | "$set", 185 | %{"Address" => "1313 Mockingbird Lane"}, 186 | ip: "123.123.123.123" 187 | ) 188 | 189 | :timer.sleep(50) 190 | end 191 | 192 | test "engage/4 with IP tuple" do 193 | MixpanelTest.engage( 194 | "13793", 195 | "$set", 196 | %{"Address" => "1313 Mockingbird Lane"}, 197 | ip: {123, 123, 123, 123} 198 | ) 199 | 200 | :timer.sleep(50) 201 | end 202 | end 203 | 204 | describe "creates an identity alias" do 205 | setup do 206 | MixpanelTest.HTTP.Mock 207 | |> expect(:post, fn url, body, _headers, _opts -> 208 | uri = parse(url) 209 | 210 | assert uri.path == "/track" 211 | assert uri.fragment == "identity-create-alias" 212 | assert uri.query == nil 213 | assert body ~> string(starts_with: "data=") 214 | 215 | {:ok, 200, [], "1"} 216 | end) 217 | 218 | :ok 219 | end 220 | 221 | test "create an alias" do 222 | MixpanelTest.create_alias("13793", "13794") 223 | :timer.sleep(50) 224 | end 225 | end 226 | 227 | test "__using__/1" do 228 | ast = 229 | quote do 230 | use Mixpanel 231 | end 232 | 233 | assert {:module, _module, _bytecode, _exports} = 234 | Module.create(MixpanelTest.Using, ast, Macro.Env.location(__ENV__)) 235 | 236 | # credo:disable-for-next-line 237 | assert apply(MixpanelTest.Using, :__info__, [:functions]) 238 | ~> in_any_order([ 239 | {:track, 1}, 240 | {:track, 2}, 241 | {:track, 3}, 242 | {:engage, 1}, 243 | {:engage, 2}, 244 | {:engage, 3}, 245 | {:engage, 4}, 246 | {:create_alias, 2} 247 | ]) 248 | end 249 | 250 | # Elixir 1.12 comparability layer 251 | # Remove when support for 1.12 is dropped 252 | defp parse(uri) do 253 | unquote( 254 | if function_exported?(URI, :new, 1) do 255 | quote do 256 | {:ok, uri} = URI.new(var!(uri)) 257 | uri 258 | end 259 | else 260 | quote do 261 | URI.parse(var!(uri)) 262 | end 263 | end 264 | ) 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /test/support/mixpanel.ex: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest do 2 | @moduledoc false 3 | 4 | use Mixpanel 5 | end 6 | -------------------------------------------------------------------------------- /test/support/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule MixpanelTest.Plug do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | 6 | import Plug.Conn 7 | 8 | plug(:match) 9 | plug(:dispatch) 10 | 11 | match _ do 12 | {:ok, body, conn} = read_body(conn) 13 | conn = fetch_query_params(conn) 14 | 15 | response = 16 | %{ 17 | body: body, 18 | method: conn.method, 19 | query_params: conn.query_params, 20 | request_headers: conn.req_headers, 21 | request_path: conn.request_path 22 | } 23 | 24 | conn 25 | |> put_resp_content_type("application/json") 26 | |> send_resp(200, Jason.encode!(response)) 27 | end 28 | end 29 | 30 | defimpl Jason.Encoder, for: Tuple do 31 | @spec encode(tuple, Jason.Encode.opts()) :: iodata | {:error, EncodeError.t() | Exception.t()} 32 | def encode(data, opts) when is_tuple(data) do 33 | Jason.Encode.list(Tuple.to_list(data), opts) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/selfsigned.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDfDCCAmSgAwIBAgIIH5qrtxLn5OwwDQYJKoZIhvcNAQELBQAwQzEaMBgGA1UE 3 | CgwRUGhvZW5peCBGcmFtZXdvcmsxJTAjBgNVBAMMHFNlbGYtc2lnbmVkIHRlc3Qg 4 | Y2VydGlmaWNhdGUwHhcNMjMxMDI2MDAwMDAwWhcNMjQxMDI2MDAwMDAwWjBDMRow 5 | GAYDVQQKDBFQaG9lbml4IEZyYW1ld29yazElMCMGA1UEAwwcU2VsZi1zaWduZWQg 6 | dGVzdCBjZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 7 | AKM4Tg08fTSRTSHmmxFMs+/FCJMjoQ9AtByLUqIH1zPI7kWTV5f5HrwofcfDZhPa 8 | N4kUq15eIdV5zkZzliRHMT1lbNsV+vU+v3fy73OiNOUTlwMWJeV7u9m6jE4k0qYN 9 | h1TdtNcew90Rij4is+lZwlZa2kPuy8zDHvppcD08NitRzzC4MEGWRyGx9pBGqUAa 10 | FIVHgzorfIL48pDbvlZmq01MnhhpIlyCv0wZjvnSo6wRVBlWZhItEEG2EOy/2i17 11 | YpVyWXhmW4br2gsPGwwR0Xx0x102lo95UetStWCpV/snKx+d57MwUTb8jcx0KklM 12 | RXICOQTFarMzWzGMP6S7KKUCAwEAAaN0MHIwDAYDVR0TAQH/BAIwADAOBgNVHQ8B 13 | Af8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQW 14 | BBQPWjbvX1PKyMvQZki7XNyxZwHRhjAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJ 15 | KoZIhvcNAQELBQADggEBAKJvxwdm6e3qgv3mQToOMbgq4oxQFy3J89dJDIkPAtPz 16 | UZUmlvhMfmq3lEqdhBSuUWtvGE/15YxiEYcTln05p88qD03eXZziE1816yBpiKm3 17 | vRwa2j2Li9rBQN514+CGQV+xkDA7CPMxWgvB/7VNSnOZBAh7mzCBTdWl0CuCJi5s 18 | wnDyqxl1qyBPwulfzPcPQ+J3kVLtpSUwBYrWWosMX3PR825kxQ3o2NRjpNkarKwT 19 | HP8uf2YKaBp2kSHIUBFOASrtGIRkZW8RglbMz47wOiZkPeXd3Vsnz6qd+/Jy4XZu 20 | 7V/ytp5F7NmgZ29zNKSIt9OFecFkkI16LLR8RoB3ZDo= 21 | -----END CERTIFICATE----- 22 | 23 | -------------------------------------------------------------------------------- /test/support/selfsigned_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAozhODTx9NJFNIeabEUyz78UIkyOhD0C0HItSogfXM8juRZNX 3 | l/kevCh9x8NmE9o3iRSrXl4h1XnORnOWJEcxPWVs2xX69T6/d/Lvc6I05ROXAxYl 4 | 5Xu72bqMTiTSpg2HVN201x7D3RGKPiKz6VnCVlraQ+7LzMMe+mlwPTw2K1HPMLgw 5 | QZZHIbH2kEapQBoUhUeDOit8gvjykNu+VmarTUyeGGkiXIK/TBmO+dKjrBFUGVZm 6 | Ei0QQbYQ7L/aLXtilXJZeGZbhuvaCw8bDBHRfHTHXTaWj3lR61K1YKlX+ycrH53n 7 | szBRNvyNzHQqSUxFcgI5BMVqszNbMYw/pLsopQIDAQABAoIBAAG1PQJdGafMBtd2 8 | IxGYLoKuqWaQlHxXvxse8RVf59ypuTWBgByR9UdIrgY5C7ads4RTrmp2c+QjKzX/ 9 | PY0WAVmZeKPuE3y+GNzSfQQ6N02TSxzaV3qSvgh2JAtA688B0OimUeyRy/Zide2q 10 | D0DgcaVkniD4dYAlPXEqQzKe+6GqBvRSkXwYNG9NHRYhhFaoga63D1FvKOSwLfYu 11 | Bj1Hd34t8vpMCLcEK10wMo1Em00/TNQs+CYuMOAT2+VnKWiWJTQSWdEWO/p9+AYn 12 | sF9v/GSDfx2e8WK3CAY4RXj/EeNm2zTHXM79jD+GTT0UegzOk+FlLMzjFHoSXo9h 13 | xTFt7WUCgYEA1Zg2p5QkmO+7sRJq594IIN2mZpeGBjVW2lyAiHVGsUNb6M5xTDyD 14 | Q8ejNy7tOZ9ROept+K6911g4W3lS+8alQZCs5p5H0Z3ctcJimDBW55M/MuFOFYAG 15 | ZeT2NeguyZm2vB1bfbn6xqUsJ/GwBPOLCxKJVYmq2DRk219wrmIyYE8CgYEAw5/W 16 | ZSiJsdoe2+OV0WmjHzQrh6ns1F5ndBmN0CBmJ2HxCkXZTNkC9ekRRyRSpT/XH1pu 17 | xOrLiOeVuxdkP82kDlwffQK1/h1tLMM4f4nPhFHBRwM1fJQODpGwPR/Zd6jTGCIk 18 | PGMOZT+Cuo2wGIdKMfiSEbhIEJgMV7ypz7LeFssCgYEAhUu3l/8Qk8zQYiHvS4I5 19 | qmEIzm9zOX6iFCW0JPSjSE6UFgZ3mC8PcAYvamnDq7ksFKujM5XBbZllmlhtnCiM 20 | yw0Bie5vPXZ53YhQxU8tfNlckGEgvLQnygEIUf3y7OcbrevYQ+8DfGJp2weuZHik 21 | ZiWMRTBjyQdxhaHbDUjEzWsCgYAHyOSPQf24xiVUOspLexiytTDGRUzXZqpXRG0Q 22 | SznFd3BQKFdtZ3Vms8+sNRXU3aWB6edejrlqyUx8FYI3x8cvixr1rpXvdtxRW7Nz 23 | 39gSO+6lFMucGYg1rDaHOC0/RcigvTsT7B02ikB5jAnl7/xT4MBvVBYKEwLquudH 24 | DKcp4QKBgQCLKOIxSGP6ZnBE3lP0IcGywuwlPiCNwByIdAD87Fg3TcIwI90Bpvp6 25 | ZYy37K2MFSrsbmba659/OXI4Xsj6JkmGsl7l+SqB9aIS4k6ReG3QtEA3jjDzGQXN 26 | 74NxOgTeowc01Up53GfmH/W8E0qnmLmrEtU7CyvJqVV5Wafl5dwIjA== 27 | -----END RSA PRIVATE KEY----- 28 | 29 | -------------------------------------------------------------------------------- /test/support/telemetry_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule Mixpanel.TelemetryCollector do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | @type event :: 7 | {:telemetry.event_name(), :telemetry.event_measurements(), :telemetry.event_metadata()} 8 | @type state :: [event] 9 | 10 | @spec start_link([[atom, ...]]) :: :ignore | {:error, any} | {:ok, pid} 11 | def start_link(event_names) do 12 | GenServer.start_link(__MODULE__, event_names) 13 | end 14 | 15 | @spec record_event( 16 | :telemetry.event_name(), 17 | :telemetry.event_measurements(), 18 | :telemetry.event_metadata(), 19 | GenServer.server() 20 | ) :: :ok 21 | def record_event(event, measurements, metadata, pid) do 22 | GenServer.cast(pid, {:event, event, measurements, metadata}) 23 | end 24 | 25 | @spec get_events(GenServer.server()) :: state 26 | def get_events(pid) do 27 | GenServer.call(pid, :get_events) 28 | end 29 | 30 | @impl GenServer 31 | @spec init([[atom, ...]]) :: {:ok, []} 32 | def init(event_names) do 33 | :telemetry.attach_many( 34 | "#{inspect(self())}.trace", 35 | event_names, 36 | &__MODULE__.record_event/4, 37 | self() 38 | ) 39 | 40 | {:ok, []} 41 | end 42 | 43 | @impl GenServer 44 | @spec handle_cast( 45 | {:event, :telemetry.event_name(), :telemetry.event_measurements(), 46 | :telemetry.event_metadata()}, 47 | state 48 | ) :: {:noreply, state} 49 | def handle_cast({:event, event, measurements, metadata}, events) do 50 | {:noreply, [{event, measurements, metadata} | events]} 51 | end 52 | 53 | @impl GenServer 54 | @spec handle_call(:get_events, any, state) :: {:reply, state, state} 55 | def handle_call(:get_events, _from, events) do 56 | {:reply, Enum.reverse(events), events} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.load(:mixpanel_api_ex) 2 | 3 | for app <- Application.spec(:mixpanel_api_ex, :applications) do 4 | Application.ensure_all_started(app) 5 | end 6 | 7 | Mox.defmock(MixpanelTest.HTTP.Mock, for: Mixpanel.HTTP) 8 | 9 | ExUnit.start() 10 | --------------------------------------------------------------------------------