diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a826375..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,144 +0,0 @@ -version: 2.1 - -jobs: - build: - docker: - - image: cimg/elixir:1.15.5-erlang-26.0.2 - environment: - MIX_ENV: test - steps: - - checkout - - run: - name: Install tools - command: | - mix local.hex --force && \ - mix local.rebar --force - - restore_cache: - keys: - - v2-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} - - v2-mix-cache-{{ .Branch }} - - v2-mix-cache - - run: - name: Get dependencies - command: mix deps.get - - save_cache: - key: v2-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} - paths: - - deps - - restore_cache: - keys: - - v4-build-cache-{{ .Branch }} - - v4-build-cache - - run: - name: Compile - command: mix do deps.compile, compile --warnings-as-errors, dialyzer --plt - - save_cache: - key: v4-build-cache-{{ .Branch }} - paths: - - _build - - persist_to_workspace: - root: ~/ - paths: - - .mix - - project/_build - - project/deps - - test: - docker: - - image: cimg/elixir:1.15.5-erlang-26.0.2 - environment: - MIX_ENV: test - - image: cimg/postgres:14.6 - steps: - - checkout - - attach_workspace: - at: ~/ - - run: - name: Run tests - command: mix test --cover --export-coverage default - - run: - name: Check coverage - command: mix test.coverage - - store_test_results: - path: /tmp/test/results.xml - - lint: - docker: - - image: cimg/elixir:1.15.5-erlang-26.0.2 - environment: - MIX_ENV: test - steps: - - checkout - - attach_workspace: - at: ~/ - - run: - name: Check formatting - command: mix format --check-formatted --dry-run - - run: - name: Check for retired dependencies - command: mix hex.audit - - run: - name: Check unused dependencies - command: mix deps.unlock --check-unused - - run: - name: Check outdated dependencies - command: mix hex.outdated --within-requirements || true - - run: - name: Credo - command: mix credo --all - - run: - name: Dialyzer - command: mix dialyzer - - run: - name: Check documentation - command: mix doctor - - security: - docker: - - image: cimg/elixir:1.15.5-erlang-26.0.2 - environment: - MIX_ENV: test - steps: - - checkout - - attach_workspace: - at: ~/ - - run: - name: Audit dependencies - command: mix deps.audit - - run: - name: Sobelow - command: mix sobelow --config - - slscan: - docker: - - image: shiftleft/sast-scan:maven385 - environment: - FETCH_LICENSE: "true" - working_directory: /tmp/shiftleft-scan - steps: - - checkout - - run: - name: Scan - command: scan --no-error - - store_artifacts: - path: reports - destination: sast-scan-reports - -workflows: - test: - jobs: - - build - - test: - requires: - - build - - lint: - requires: - - build - - security: - requires: - - build - - slscan: - filters: - branches: - only: - - master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5969888 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI +on: + push: + branches: [main,master] + pull_request: + branches: [main,master] +jobs: + test: + name: Test (Elixir ${{ matrix.elixir }} | OTP ${{ matrix.otp }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - elixir: 1.18.x + otp: 27 + os: ubuntu-22.04 + - elixir: 1.19.x + otp: 28 + os: ubuntu-22.04 + env: + MIX_ENV: test + steps: + - name: Setup Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + id: cache-deps + with: + path: | + deps + _build + key: | + mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}- + - name: Install dependencies + run: mix deps.get + + - name: Compile + run: mix compile + + - name: Check for unused packages + run: mix deps.unlock --check-unused + + - run: mix format --check-formatted + + - run: mix credo --strict + + - run: mix dialyzer + + - name: Check for abandonded packages + run: mix hex.audit + + - name: Check outdated dependencies + run: mix hex.outdated --within-requirements || true + + - name: Check for vulnerable packages + run: mix hex.audit + + - name: Run tests + run: mix test + + - name: Run tests (with coverage) + run: mix test --cover --export-coverage default + + - name: Scan for security vulnerabilities + run: mix sobelow --exit --threshold medium + + publish: + name: Publish (Dry Run) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + elixir-version: 1.18 + otp-version: 27 + - name: Fetch dependencies + run: mix deps.get + - name: Compile + run: mix compile + - name: Publish package + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + run: mix hex.publish --organization ${{ vars.HEX_ORG }} --dry-run --replace --yes diff --git a/README.md b/README.md index 888a8e6..7b01eab 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Zexbox [![Hex.pm](https://img.shields.io/hexpm/v/zexbox.svg)](https://hex.pm/packages/zexbox) -[![CircleCI](https://dl.circleci.com/status-badge/img/gh/Intellection/zexbox/tree/master.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/Intellection/zexbox/tree/master) +[![CI](https://github.com/Intellection/zexbox/actions/workflows/ci.yml/badge.svg)](https://github.com/Intellection/zexbox/actions/workflows/ci.yml) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/zexbox/api-reference.html) ## Installation @@ -16,6 +16,8 @@ end ## LaunchDarkly Feature Flags +The Zexbox library provides an idiomatic Elixir wrapper around the LaunchDarkly Erlang SDK with support for contexts, multi-contexts, and all modern LaunchDarkly features. + ### Configuration Configuration is fairly simple, with the only required piece of configuration being the `sdk_key`. For production environments we recommend also including `:email` as a private attribute: @@ -80,7 +82,35 @@ end Stopping a client with a custom tag can be done using the `Zexbox.Flags.stop/1` function. -Evaluating a flag can be achieved by simply calling the `Zexbox.Flags.variation/3` function. +### Using Contexts + +Contexts are the recommended way to evaluate feature flags. They provide type safety and support for multi-entity targeting: + +```elixir +alias Zexbox.Flags.Context + +# Simple user context +context = Context.new("user-123") +Zexbox.Flags.variation("my-flag", context, false) + +# Context with attributes +context = + Context.new("user-123") + |> Context.set("email", "user@example.com") + |> Context.set("plan", "enterprise") + |> Context.set_private_attributes(["email"]) + +Zexbox.Flags.variation("premium-feature", context, false) + +# Multi-context (target based on user AND organization) +user = Context.new("user-123", "user") +org = Context.new("org-456", "organization") +multi = Context.new_multi([user, org]) + +Zexbox.Flags.variation("enterprise-feature", multi, false) +``` + +**Backward Compatibility**: Raw maps are still supported: ```elixir Zexbox.Flags.variation( @@ -90,6 +120,8 @@ Zexbox.Flags.variation( ) ``` +For more details, see the [Context Module Guide](CONTEXT_MODULE_GUIDE.md). + ## Logging Default logging can be attached to your controllers by calling `Zexbox.Logging.attach_controller_logs!` in the `start/2` function of your `Application` module: diff --git a/test/zexbox/metrics/client_test.exs b/test/zexbox/metrics/client_test.exs index fd585d0..44617e5 100644 --- a/test/zexbox/metrics/client_test.exs +++ b/test/zexbox/metrics/client_test.exs @@ -5,11 +5,6 @@ defmodule Zexbox.Metrics.ClientTest do alias Zexbox.Metrics.{Client, Connection, Series} alias Zexbox.Metrics.ContextRegistry - setup_all do - ensure_registry_started() - :ok - end - @map %{ measurement: "my_measurement", fields: %{ @@ -21,6 +16,11 @@ defmodule Zexbox.Metrics.ClientTest do } describe "write_metric/1" do + setup do + start_supervised!(ContextRegistry) + :ok + end + test_with_mock "writes the metric when given a series", Connection, write: fn metrics -> {:ok, metrics} end do series = struct(Series, @map) @@ -60,11 +60,4 @@ defmodule Zexbox.Metrics.ClientTest do Zexbox.Metrics.enable_for_process() end end - - defp ensure_registry_started do - case Process.whereis(ContextRegistry) do - nil -> {:ok, _pid} = ContextRegistry.start_link() - _pid -> :ok - end - end end diff --git a/test/zexbox/metrics/context_registry_test.exs b/test/zexbox/metrics/context_registry_test.exs index 26c6d00..3e70128 100644 --- a/test/zexbox/metrics/context_registry_test.exs +++ b/test/zexbox/metrics/context_registry_test.exs @@ -3,12 +3,14 @@ defmodule Zexbox.Metrics.ContextRegistryTest do alias Zexbox.Metrics.ContextRegistry - setup_all do - ensure_registry_started() - :ok - end - describe "register/1, unregister/1, disabled?/1" do + setup do + # Start a supervised ContextRegistry for each test + # This ensures a clean state and proper cleanup + start_supervised!(ContextRegistry) + :ok + end + test "registers and unregisters a pid" do pid = self() @@ -59,11 +61,4 @@ defmodule Zexbox.Metrics.ContextRegistryTest do eventually(predicate, attempts - 1) end end - - defp ensure_registry_started do - case Process.whereis(ContextRegistry) do - nil -> {:ok, _pid} = ContextRegistry.start_link() - _pid -> :ok - end - end end diff --git a/test/zexbox/metrics/context_test.exs b/test/zexbox/metrics/context_test.exs index 6234bb4..b068a20 100644 --- a/test/zexbox/metrics/context_test.exs +++ b/test/zexbox/metrics/context_test.exs @@ -3,12 +3,12 @@ defmodule Zexbox.Metrics.ContextTest do alias Zexbox.Metrics.{Context, ContextRegistry} - setup_all do - ensure_registry_started() - :ok - end - describe "disable_for_process/0 and enable_for_process/0" do + setup do + start_supervised!(ContextRegistry) + :ok + end + test "toggles disabled? for the current process" do assert Context.disabled?() == false @@ -34,11 +34,4 @@ defmodule Zexbox.Metrics.ContextTest do :ok = Zexbox.Metrics.enable_for_process() end end - - defp ensure_registry_started do - case Process.whereis(ContextRegistry) do - nil -> {:ok, _pid} = ContextRegistry.start_link() - _pid -> :ok - end - end end diff --git a/test/zexbox/metrics/metric_handler_test.exs b/test/zexbox/metrics/metric_handler_test.exs index 850ebd9..dd845d8 100644 --- a/test/zexbox/metrics/metric_handler_test.exs +++ b/test/zexbox/metrics/metric_handler_test.exs @@ -5,11 +5,6 @@ defmodule Zexbox.Metrics.MetricHandlerTest do alias Zexbox.Metrics.{Connection, ControllerSeries, MetricHandler} alias Zexbox.Metrics.ContextRegistry - setup_all do - ensure_registry_started() - :ok - end - defmodule MockClient do @spec write_metric(ControllerSeries.t()) :: ControllerSeries.t() def write_metric(metric) do @@ -19,6 +14,8 @@ defmodule Zexbox.Metrics.MetricHandlerTest do describe "handle_event/4" do setup do + start_supervised!(ContextRegistry) + event = [:phoenix, :endpoint, :stop] measurements = %{duration: 1_000_000_000} @@ -101,10 +98,12 @@ defmodule Zexbox.Metrics.MetricHandlerTest do end test "captures and logs any exceptions", %{event: event, metadata: metadata} do - assert capture_log(fn -> - MetricHandler.handle_event(event, nil, metadata, nil) - end) =~ - "Exception creating controller series: %KeyError" + log = + capture_log(fn -> + MetricHandler.handle_event(event, nil, metadata, nil) + end) + + assert log =~ "Exception creating controller series:" end test "does not call Connection.write when process has disabled metrics", %{ @@ -120,11 +119,4 @@ defmodule Zexbox.Metrics.MetricHandlerTest do end end end - - defp ensure_registry_started do - case Process.whereis(ContextRegistry) do - nil -> {:ok, _pid} = ContextRegistry.start_link() - _pid -> :ok - end - end end diff --git a/test/zexbox/metrics/models/series_test.exs b/test/zexbox/metrics/models/series_test.exs index fdbc8fb..26b0c50 100644 --- a/test/zexbox/metrics/models/series_test.exs +++ b/test/zexbox/metrics/models/series_test.exs @@ -35,7 +35,7 @@ defmodule Zexbox.Metrics.SeriesTest do metric = Series.new(measurement) assert metric.measurement == measurement - refute is_nil(metric.timestamp) + assert %DateTime{} = metric.timestamp end test "field/3" do