# Impact SDK (Elixir)
OpenTelemetry-native LLM/AI tracing SDK for Elixir applications, exporting to Impact via OTLP.
This is the BEAM-side peer of [`impact-sdk`](https://github.com/Impact-AI/impact-sdk) (Python) and [`impact-sdk-js`](https://github.com/Impact-AI/impact-sdk-js) (JavaScript/TypeScript). The three SDKs share a canonical attribute schema so a trace produced by any one of them looks identical on the wire.
## Design goals
1. Align with `impact-sdk` (Python) and `impact-sdk-js` (JS/TS) runtime semantics and attribute schema.
2. Keep the public API small, idiomatic, and predictable.
3. Be safe in BYO-OTel environments (`:auto | :bootstrap | :attach`).
4. Never hard-fail customer apps due to optional instrumentation.
## Installation
Add `:impact` to your `mix.exs`:
```elixir
def deps do
[
{:impact, "~> 0.0.1"}
]
end
```
```bash
mix deps.get
```
## Configuration
Impact integrates in one line, from `config/runtime.exs`:
```elixir
# config/runtime.exs
import Config
Impact.Runtime.configure!()
```
That's it. At boot, `Impact.Runtime.configure!/1` reads `IMPACT_API_KEY`
(required) and `IMPACT_BASE_URL` (optional — derived from the key's region if
absent) and wires the OpenTelemetry OTLP/HTTP exporter to send to Impact. The
`:impact` OTP application auto-initialises after that — no `Impact.init/1`
call is required for default setups.
### Why `config/runtime.exs`
The Erlang OpenTelemetry exporter reads its endpoint and auth headers from
Application env *at supervision-tree startup*. `config/runtime.exs` is the
standard Elixir hook that runs **after compile but before any application
starts**, which is the exact seam the exporter needs.
Calling `Impact.init/1` from inside an already-running supervision tree (e.g.
your `Application.start/2`) is too late — the exporter has already booted with
empty config and won't pick up new values.
### Required environment variables
| Var | Purpose |
| ---------------------- | --------------------------------------------------------- |
| `IMPACT_API_KEY` | Required. Read at boot by `Impact.Runtime.configure!/1`. |
| `IMPACT_BASE_URL` | Optional. Derived from the key's region if absent. |
| `OTEL_SERVICE_NAME` | Optional. Defaults to `impact-app`. |
| `IMPACT_DIAG_LOG_LEVEL`| Optional. `none \| error \| warn \| info \| debug \| verbose`. |
### Overrides
Pass any value as a keyword option to override env-var resolution:
```elixir
Impact.Runtime.configure!(
api_key: "impact_dev_...",
endpoint: "https://api.dev.impact.ai",
service_name: "my_app",
resource_attributes: %{deployment: %{environment: "staging"}}
)
```
### Scripts and Mix tasks
`config/runtime.exs` is only evaluated when starting your application. For
one-off scripts (`mix run path.exs`), call `Impact.Runtime.configure!/1` at
the top of the script and invoke Mix with `--no-start` so it runs before the
exporter boots:
```elixir
Impact.Runtime.configure!(api_key: "impact_dev_...")
{:ok, _} = Application.ensure_all_started(:impact)
```
```bash
mix run --no-start path/to/script.exs
```
See [`test/integration/aml_analyst_example.exs`](test/integration/aml_analyst_example.exs)
for a complete working example.
## Quick start
```elixir
Impact.context(
user_id: "user_123",
interaction_id: "interaction_456",
version_id: "v1.0.0",
attributes: %{team: "growth"}
)
require Impact
Impact.trace [type: :workflow, name: "checkout"] do
do_work()
end
```
## Public API
| Function | Purpose |
| --------------------------------------- | ---------------------------------------------------- |
| `Impact.Runtime.configure!/1` | Wire OTel exporter (call from `config/runtime.exs`) |
| `Impact.init/1` | Manual init / runtime override (not needed for default setup) |
| `Impact.context/1` | Attach context for the current execution |
| `Impact.trace/2` (macro) / `trace/2` (fn) | Wrap an expression in a manual Impact span |
| `Impact.flush/1` | Force-flush pending spans |
| `Impact.shutdown/1` | Flush and shut down exporter |
| `Impact.instrumentation_results/0` | Deterministic optional-instrumentation outcomes |
| `Impact.Instrumentation.Bedrock.request/1` | Auto-instrument an AWS Bedrock Req call (LLM span) |
## Instrumenting AWS Bedrock
For applications that call AWS Bedrock via `Req`, use
`Impact.Instrumentation.Bedrock.request/1` in place of `Req.request/1`. It
emits an LLM span tagged with GenAI semantic-convention attributes
(`gen_ai.system`, `gen_ai.request.model`, token usage, tool detection) without
any call-site `Impact.trace` boilerplate:
```elixir
req =
Req.new(
method: :post,
url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/#{arn}/converse",
json: %{"messages" => [...], "inferenceConfig" => %{...}, "toolConfig" => %{...}}
)
{:ok, response} = Impact.Instrumentation.Bedrock.request(req)
```
Add `{:req, "~> 0.5"}` to your `mix.exs` — `Req` is declared as an optional
dep of `:impact`, so apps that don't use this instrumentor don't pull it in.
### What it captures vs what stays manual
| Span type | Source |
| -------------------- | ------------------------------------------------------ |
| `bedrock.converse` | **Auto** via `Impact.Instrumentation.Bedrock.request/1` |
| Workflow / step root | Manual: `Impact.trace [type: :workflow, name: ...]` |
| Tool execution | Manual: `Impact.trace [type: :tool, name: tool_name]` |
**Why tool execution is manual:** the Bedrock instrumentor sees the LLM
request the model made for a tool (captured as the `gen_ai.tool.names` attribute
on the LLM span), but the tool itself is *your* code — a function in your
app's tool registry. The SDK can't know which functions are tools, only which
HTTP calls are LLM calls. The same is true in the Python and JS SDKs: tools
are decorated by hand with `@trace(type="tool")` / `impact.trace("tool_name", fn)`.
In the AML Analyst demo, the agent server wraps each tool execution:
```elixir
Impact.trace [type: :tool, name: tool_call.name, attributes: %{tool_call_id: tool_call.id}] do
ToolRegistry.execute(tool_call.name, tool_call.input)
end
```
That puts each tool call in the trace tree under the parent agent loop, so you
see `workflow.alert.investigate → task.agent.loop → llm.bedrock.converse → tool.search_kyc`.
## Canonical schema
Context attributes emitted on every span created in the current execution:
| Input (Elixir) | Attribute emitted on the wire |
| --------------------------- | --------------------------------- |
| `user_id: "u_123"` | `impact.context.user_id` |
| `interaction_id: "int_abc"` | `impact.context.interaction_id` |
| `version_id: "v1"` | `impact.context.version_id` |
| `attributes: %{team: "x"}` | `impact.context.team` (and so on) |
Manual span attributes emitted by `Impact.trace`:
* `impact.trace.type`
* `impact.trace.name`
* `impact.trace.path` (dot-separated nested span stack)
* `impact.trace.input`
* `impact.trace.output`
These keys are the cross-SDK invariant. They MUST stay byte-identical to what `impact-sdk` (Python) and `impact-sdk-js` (JS/TS) emit.
## Runtime modes
1. `:auto` (default) — attach to an existing tracer provider if one is configured, otherwise bootstrap.
2. `:bootstrap` — always (re)configure `:opentelemetry` + `:opentelemetry_exporter` with the Impact OTLP/HTTP-protobuf exporter.
3. `:attach` — never replace caller-configured providers; raise if no usable provider is found.
Endpoint resolution order:
1. `Impact.init(endpoint: ...)`
2. `IMPACT_BASE_URL`
3. derived from `impact_<region>_*` API key (`https://api.<region>.impact.ai`)
## Environment variables
* `IMPACT_BASE_URL` — required if not passed to `init/1` and not derivable from the key
* `IMPACT_API_KEY` — optional if passed to `init/1`
* `IMPACT_DIAG_LOG_LEVEL` — `none | error | warn | info | debug | verbose`
* `OTEL_SERVICE_NAME` — defaults to `impact-app`
## Coverage (initial scope)
BEAM ecosystem GenAI/instrumentation landscape is smaller than Python/JS, so this matrix is intentionally narrow at v0.
Status legend:
1. `Covered`: `Impact.init/1` can auto-enable instrumentation (best-effort) when the customer SDK is present.
2. `Model calls only`: model SDK spans can be captured, but no dedicated orchestration spans.
3. `Not supported`: no stable Elixir instrumentation path is integrated today.
Coverage:
| Provider / Framework | Layer | Strategy | Status |
| -------------------------- | ---------------- | --------------------------------------------------- | ------ |
| AWS Bedrock (Converse / Invoke) | Model | `Impact.Instrumentation.Bedrock.request/1` (Req) | **Covered** |
| OpenAI (`openai_ex`) | Model | Impact wrapper + `:telemetry` bridge | Planned |
| Anthropic (`anthropix`) | Model | `:telemetry` bridge | Planned |
| Google Gemini (`gemini_ex`)| Model | Impact wrapper | Planned |
| LangChain.ex (`langchain`) | Agent framework | `:telemetry` bridge | Planned |
| Phoenix | HTTP root spans | `:opentelemetry_phoenix` | Planned |
| Ecto | DB spans | `:opentelemetry_ecto` | Planned |
| Finch / Req | HTTP client | `:opentelemetry_finch` | Planned |
Not supported:
| Provider / Framework | Notes |
| -------------------- | -------------------------------------------------- |
| CrewAI / Agno | No BEAM port; out of scope |
## Validation
```bash
mix deps.get
mix format --check-formatted
mix compile --warnings-as-errors
mix test
```
`test/contracts/` is the cross-SDK contract guard suite — it asserts that the Elixir SDK emits the same canonical attribute keys as the Python and JS SDKs.
## License
Apache-2.0