Skip to main content

lib/impact.ex

defmodule Impact do
  @moduledoc """
  Impact is an OpenTelemetry-native SDK for capturing LLM/AI traces from Elixir
  applications and exporting them to Impact via OTLP.

  The Elixir SDK is the BEAM-side peer of `impact-sdk` (Python) and `impact-sdk-js`
  (JavaScript/TypeScript). The three SDKs share a canonical attribute schema:

    * `impact.context.<key>` for app-supplied request context
    * `impact.trace.{type,name,path,input,output}` for manual spans

  ## Quick start

      Impact.init(
        api_key: System.fetch_env!("IMPACT_API_KEY"),
        endpoint: System.get_env("IMPACT_BASE_URL"),
        mode: :auto
      )

      Impact.context(
        user_id: "u_123",
        interaction_id: "int_abc",
        attributes: %{team: "growth"}
      )

      Impact.trace [type: :workflow, name: "checkout"] do
        do_work()
      end

  ## Runtime modes

  Mirrors the Python and JS SDKs:

    * `:auto` (default) — attach to an existing tracer provider if one is configured,
      otherwise bootstrap.
    * `:bootstrap` — always (re)configure `:opentelemetry` exporter + batch processor.
    * `:attach` — never replace caller-configured providers; raises if no usable
      provider is found.
  """

  alias Impact.{Config, Context, Otel}

  @type init_opt ::
          {:api_key, String.t() | nil}
          | {:endpoint, String.t() | nil}
          | {:service_name, String.t() | nil}
          | {:mode, :auto | :bootstrap | :attach}
          | {:capture_content, boolean()}
          | {:diag_log_level, :none | :error | :warn | :info | :debug | :verbose}

  @doc """
  Initialize Impact. Idempotent — calling twice replays the last configuration.
  """
  @spec init([init_opt()]) :: :ok | {:error, term()}
  def init(opts \\ []) do
    with {:ok, cfg} <- Config.build(opts) do
      Otel.setup(cfg)
    end
  end

  @doc """
  Attach context to the current execution. Keys are emitted as `impact.context.<key>`
  span attributes for every span created under this process / OTel context.

  Reserved keys are flattened from `:attributes`. The canonical reserved keys are:
  `:user_id`, `:interaction_id`, `:version_id`.
  """
  @spec context(keyword() | map()) :: :ok
  def context(ctx), do: Context.put(ctx)

  @doc """
  Wrap an expression in a manual Impact span.

  Both block and function forms are supported. Block form is a macro so the
  block body is evaluated lazily inside the span:

      require Impact

      Impact.trace [type: :task, name: "payment.authorize"] do
        authorize(order_id)
      end

  Function form for non-block use:

      Impact.trace([type: :task, name: "payment.authorize"], fn ->
        authorize(order_id)
      end)
  """
  defmacro trace(opts, do: block) do
    quote do
      Impact.Trace.with_span(unquote(opts), fn -> unquote(block) end)
    end
  end

  defmacro trace(opts, fun) do
    quote do
      Impact.Trace.with_span(unquote(opts), unquote(fun))
    end
  end

  @doc """
  Add attributes to the currently active span. Useful for response-side
  attributes that are only known after an operation completes.

      Impact.trace [type: :llm, name: "bedrock_call", attributes: request_attrs] do
        result = call_llm()
        Impact.set_attributes(%{"gen_ai.usage.input_tokens" => result.tokens})
        result
      end

  Thin macro over `OpenTelemetry.Tracer.set_attributes/1` — re-exported so
  apps don't need to add `:opentelemetry_api` to their deps just to set
  mid-span attributes.
  """
  defmacro set_attributes(attrs) do
    quote do
      require OpenTelemetry.Tracer
      OpenTelemetry.Tracer.set_attributes(unquote(attrs))
    end
  end

  @doc """
  Set the status of the currently active span. `code` is `:ok` | `:error` |
  `:unset`. `message` is an optional string (typically required for `:error`).

      Impact.trace [type: :task, name: "do_thing"] do
        case do_thing() do
          {:ok, _} = ok -> ok
          {:error, reason} = err ->
            Impact.set_status(:error, inspect(reason))
            err
        end
      end
  """
  defmacro set_status(code, message \\ "") do
    quote do
      require OpenTelemetry.Tracer
      OpenTelemetry.Tracer.set_status(unquote(code), unquote(message))
    end
  end

  @doc """
  Add a timestamped event to the currently active span. Useful for marking
  notable points within a long-running span (state transitions, retries,
  cache hits, etc).

      Impact.add_event("cache_miss", %{key: cache_key})
  """
  defmacro add_event(name, attrs \\ %{}) do
    quote do
      require OpenTelemetry.Tracer
      OpenTelemetry.Tracer.add_event(unquote(name), unquote(attrs))
    end
  end

  @doc """
  Flush pending spans synchronously. Useful in scripts, Lambda-style runtimes,
  and tests.
  """
  @spec flush(timeout :: pos_integer()) :: :ok
  def flush(timeout \\ 5_000), do: Otel.force_flush(timeout)

  @doc """
  Flush and shut down Impact's exporter / batch processor. Safe to call from
  `Application.stop/1`.
  """
  @spec shutdown(timeout :: pos_integer()) :: :ok
  def shutdown(timeout \\ 5_000), do: Otel.shutdown(timeout)

  @doc """
  Returns the deterministic outcome of optional instrumentation activation,
  aligned with `impact.instrumentationResults` in the JS SDK.
  """
  @spec instrumentation_results() :: %{optional(atom()) => :ok | {:error, term()}}
  def instrumentation_results, do: Otel.instrumentation_results()
end