Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,38 @@ RUN_SDK_INTEGRATION_TESTS=1 ./scripts/run-unit-tests.sh

## Environment variable naming

All SDK config uses the `HA_` prefix. `AT_` and `TA_` are legacy aliases accepted during migration.
All SDK config uses the `HARNESS_` prefix. `HA_`, `AT_`, and `TA_` are legacy aliases accepted for backwards compatibility. When a setting is defined under multiple prefixes, precedence is `HARNESS_` > `HA_` > `AT_` > `TA_`. Resolution lives in `src/harness_sdk/env.py` (`get_env_value`, `is_env_var_present`, `is_harness_flag_enabled`).

Key variables:
| Variable | Purpose |
|---|---|
| `HA_SERVICE_NAME` | Service name on all spans |
| `HA_REPORTING_ENDPOINT` | OTLP endpoint URL |
| `HA_REPORTING_TOKEN` | Auth token (`x-harness-service-token` header) |
| `HA_REPORTING_TRACE_REPORTER_TYPE` | `OTLP` (gRPC) or `OTLP_HTTP` |
| `HA_REPORTING_SECURE` | `true`/`false` for TLS |
| `HA_REPORTING_COMPRESSION` | `gzip` or empty |
| `HA_CONTROL_PLUGINS` | Comma-separated control plugin names |
| `HA_OBSERVABILITY_PLUGINS` | Comma-separated observability plugin names |
| `HA_ENABLE_CONSOLE_SPAN_EXPORTER` | Set to any value to dump spans to stdout |
| `HA_CONFIG_FILE` | Path to YAML config file (overrides env) |
| `HA_GEN_AI_ENABLED` | Enable/disable GenAI instrumentation |
| `HA_GEN_AI_PAYLOAD_CAPTURE_ENABLED` | Capture LLM prompt/response payloads |
| `HA_GEN_AI_PAYLOAD_EVALUATION_ENABLED` | Run control plugins on GenAI spans |
| `HARNESS_SERVICE_NAME` | Service name on all spans |
| `HARNESS_REPORTING_ENDPOINT` | OTLP endpoint URL |
| `HARNESS_REPORTING_TOKEN` | Auth token (`x-harness-service-token` header) |
| `HARNESS_REPORTING_TRACE_REPORTER_TYPE` | `OTLP` (gRPC) or `OTLP_HTTP` |
| `HARNESS_REPORTING_SECURE` | `true`/`false` for TLS |
| `HARNESS_REPORTING_COMPRESSION` | `gzip` or empty |
| `HARNESS_CONTROL_PLUGINS` | Comma-separated control plugin names |
| `HARNESS_OBSERVABILITY_PLUGINS` | Comma-separated observability plugin names |
| `HARNESS_ENABLE_CONSOLE_SPAN_EXPORTER` | Set to any value to dump spans to stdout |
| `HARNESS_CONFIG_FILE` | Path to YAML config file (overrides env) |
| `HARNESS_GEN_AI_PAYLOAD_CAPTURE_ENABLED` | Capture LLM prompt/response payloads |
| `HARNESS_GEN_AI_PAYLOAD_EVALUATION_ENABLED` | Run control plugins on GenAI spans |

### Instrumentation opt-in (strict `HARNESS_` prefix, no legacy aliases)

Instrumentation is opt-in: `Agent().instrument()` instruments nothing unless a flag below is set to `true`. Categorization and gating live in `src/harness_sdk/instrumentation/instrumentation_definitions.py` (`is_library_enabled`, `is_api_instrumentation_enabled`, `any_ai_provider_enabled`), enforced in `Agent.instrument()`.

| Variable | Enables |
|---|---|
| `HARNESS_ENABLE_API` | All non-AI instrumentation: HTTP servers/clients, gRPC, MySQL/PostgreSQL, botocore, and generic OTel contrib fallback |
| `HARNESS_ENABLE_AI_OPENAI` | OpenAI |
| `HARNESS_ENABLE_AI_ANTHROPIC` | Anthropic |
| `HARNESS_ENABLE_AI_LITELLM` | LiteLLM |
| `HARNESS_ENABLE_AI_GOOGLE_GENAI` | Google GenAI (Gemini / Vertex AI) |
| `HARNESS_ENABLE_AI_MCP` | Model Context Protocol |

The legacy `HA_GEN_AI_ENABLED` master switch no longer controls instrumentation. `skip_libraries=[...]` on `instrument()` still takes precedence over enable flags.

## Plugin system

Expand Down
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pip install -e ".[dev,anthropic,google-genai,openai,litellm]"
./scripts/run-unit-tests.sh
```

Environment variables for SDK configuration use the `HA_` prefix (for example `HA_SERVICE_NAME`, `HA_REPORTING_ENDPOINT`).
Environment variables for SDK configuration use the `HARNESS_` prefix (for example `HARNESS_SERVICE_NAME`, `HARNESS_REPORTING_ENDPOINT`). The legacy `HA_`, `AT_`, and `TA_` prefixes remain supported for backwards compatibility; when the same setting is defined under multiple prefixes, `HARNESS_` wins, then `HA_`, then `AT_`, then `TA_`.

### Integration tests (MySQL / PostgreSQL)

Expand Down Expand Up @@ -108,10 +108,42 @@ agent.instrument()
Auto-instrumentation:

```bash
export HA_CONFIG_FILE=/path/to/config.yaml
export HARNESS_CONFIG_FILE=/path/to/config.yaml
harness-instrument python app.py
```

## Enabling instrumentation (opt-in)

Instrumentation is **opt-in**. By default `agent.instrument()` and
`harness-instrument` instrument nothing; you must explicitly enable the
categories you want. A flag is "on" only when its value is (case-insensitively)
`true`; any other value — or an unset variable — leaves it off.

| Category | Environment variable | Enables |
|----------|----------------------|---------|
| API / HTTP | `HARNESS_ENABLE_API` | All non-AI instrumentation: HTTP servers (Django, Flask, FastAPI), HTTP clients (requests, HTTPX, aiohttp), gRPC, databases (MySQL, PostgreSQL), botocore, and any installed OpenTelemetry contrib instrumentors (generic fallback) |
| AI: OpenAI | `HARNESS_ENABLE_AI_OPENAI` | OpenAI instrumentation |
| AI: Anthropic | `HARNESS_ENABLE_AI_ANTHROPIC` | Anthropic instrumentation |
| AI: LiteLLM | `HARNESS_ENABLE_AI_LITELLM` | LiteLLM instrumentation |
| AI: Google GenAI | `HARNESS_ENABLE_AI_GOOGLE_GENAI` | Google GenAI (Gemini / Vertex AI) instrumentation |
| AI: MCP | `HARNESS_ENABLE_AI_MCP` | Model Context Protocol instrumentation |

Each AI provider is enabled independently. The `HARNESS_ENABLE_*` opt-in flags
are **only** read under the `HARNESS_` prefix — they have no `HA_`/`AT_`/`TA_`
aliases. The legacy `HA_GEN_AI_ENABLED` master switch no longer enables or
disables AI instrumentation; use the per-provider flags instead.

```bash
# Enable HTTP/API + OpenAI only
export HARNESS_ENABLE_API=true
export HARNESS_ENABLE_AI_OPENAI=true
harness-instrument python app.py
```

`agent.instrument(skip_libraries=[...])` still works and takes precedence: a
library listed in `skip_libraries` is never instrumented even if its category
is enabled.

## Plugins

The SDK loads extensions via [setuptools entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html). Each plugin has a **name** (the entry-point key). Names are listed in config or environment variables; only installed plugins are loaded, in the order you configure.
Expand Down
153 changes: 153 additions & 0 deletions docs/sdk-quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Send traces to Harness UDP ingest

A short integration: install the SDK, opt into the instrumentation you want,
call `Agent().instrument()` once at startup, and point a few `HARNESS_*`
environment variables at Harness UDP ingest. Instrumentation is opt-in — you
choose HTTP/API instrumentation and each AI provider explicitly — and the SDK
exports OTLP/HTTP spans to ingest.

> Environment variables use the `HARNESS_` prefix. The legacy `HA_`, `AT_`, and
> `TA_` prefixes still work for backwards compatibility (precedence:
> `HARNESS_` > `HA_` > `AT_` > `TA_`).

## 1. Install

The SDK is published on PyPI as `harness-sdk`. Pin the published release in
applications and CI builds:

```bash
pip install "harness-sdk==1.0.1"
```

Add the extra for the LLM client you use:

```bash
pip install "harness-sdk[litellm]==1.0.1" # for LiteLLM
pip install "harness-sdk[anthropic]==1.0.1" # for the Anthropic client
```

> Anthropic in-process spans additionally require the OTel GenAI helpers, which
> are not bundled:
> `pip install opentelemetry-instrumentation-anthropic opentelemetry-util-genai`

## 2. Configure (environment)

| Variable | Value |
|---|---|
| `HARNESS_SERVICE_NAME` | your service name |
| `HARNESS_REPORTING_ENDPOINT` | `https://app.harness.io/udp-ingest/otel/v1/traces?accountIdentifier=<ACCOUNT_ID>&routingId=<ACCOUNT_ID>` |
| `HARNESS_REPORTING_TRACE_REPORTER_TYPE` | `OTLP_HTTP` |
| `HARNESS_REPORTING_TOKEN` | a Harness **service account token** |

```bash
export HARNESS_SERVICE_NAME="my-service"
export HARNESS_REPORTING_ENDPOINT="https://app.harness.io/udp-ingest/otel/v1/traces?accountIdentifier=<ACCOUNT_ID>&routingId=<ACCOUNT_ID>"
export HARNESS_REPORTING_TRACE_REPORTER_TYPE=OTLP_HTTP
export HARNESS_REPORTING_TOKEN="<HARNESS_SERVICE_TOKEN>"
```

The token is sent to ingest as the `x-harness-service-token` header.

## 3. Opt into instrumentation

Instrumentation is opt-in: nothing is instrumented until you enable it. Set one
flag for all HTTP/API instrumentation, and one flag per AI provider you use. A
flag is on only when its value is `true`.

| Variable | Enables |
|---|---|
| `HARNESS_ENABLE_API` | All non-AI instrumentation (HTTP servers/clients, gRPC, DB, botocore, generic OTel contrib) |
| `HARNESS_ENABLE_AI_OPENAI` | OpenAI |
| `HARNESS_ENABLE_AI_ANTHROPIC` | Anthropic |
| `HARNESS_ENABLE_AI_LITELLM` | LiteLLM |
| `HARNESS_ENABLE_AI_GOOGLE_GENAI` | Google GenAI (Gemini / Vertex AI) |
| `HARNESS_ENABLE_AI_MCP` | Model Context Protocol |

```bash
# Example: HTTP/API instrumentation plus LiteLLM
export HARNESS_ENABLE_API=true
export HARNESS_ENABLE_AI_LITELLM=true
```

## 4. Instrument (two lines)

Call this once, as early as possible in your process startup — before you make
LLM calls.

```python
from harness_sdk.agent import Agent

Agent().instrument()
```

That's the entire code change. Everything below is just your normal app code.

---

## Example: LiteLLM

```python
# Requires: export HARNESS_ENABLE_AI_LITELLM=true
from harness_sdk.agent import Agent
Agent().instrument() # do this first, at startup

import litellm

resp = litellm.completion(
model="anthropic/claude-3-5-sonnet-20241022",
messages=[{"role": "user", "content": "Reply with one short sentence."}],
max_tokens=64,
)
print(resp.choices[0].message.content)
```

Each `litellm.completion` / `acompletion` / `embedding` / `aembedding` call now
emits a `litellm_request` span with `gen_ai.*` attributes, exported to ingest.

## Example: Anthropic Python client

```python
# Requires: export HARNESS_ENABLE_AI_ANTHROPIC=true
from harness_sdk.agent import Agent
Agent().instrument() # do this first, at startup

import anthropic

client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
msg = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=64,
messages=[{"role": "user", "content": "Reply with one short sentence."}],
)
print(msg.content[0].text)
```

`Messages.create` / `stream` (sync and async) are wrapped automatically and emit
GenAI spans to ingest.

---

## Scope the instrumentation (optional)

Because instrumentation is opt-in, you scope it by enabling only the flags you
need — for example, set `HARNESS_ENABLE_AI_LITELLM=true` and leave the other
`HARNESS_ENABLE_*` flags unset to instrument LiteLLM only.

For finer control you can also exclude an otherwise-enabled library at the call
site; `skip_libraries` takes precedence over the enable flags:

```python
# HTTP/API enabled, but never instrument requests.
# export HARNESS_ENABLE_API=true
Agent().instrument(skip_libraries=["requests"])
```

## Useful toggles (optional)

| Variable | Effect |
|---|---|
| `HARNESS_GEN_AI_PAYLOAD_CAPTURE_ENABLED` | `false` to omit prompt/response bodies from spans |
| `HARNESS_SPAN_ATTRIBUTES` | extra attributes on every span, e.g. `env=prod,team=ai` |
| `HARNESS_OBSERVABILITY_PLUGINS` | `builtin_span_attributes` to keep instrumentation but disable the SDK's own OTLP exporter (if you already export spans yourself) |
| `HARNESS_ENABLED` | `false` to disable the SDK entirely (no code change) |

11 changes: 9 additions & 2 deletions src/harness_sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
DJANGO_KEY,
FAST_API_KEY,
instrument_supported_contrib_without_wrapper,
is_library_enabled,
is_api_instrumentation_enabled,
any_ai_provider_enabled,
)
from harness_sdk.instrumentation.genai_env import maybe_set_genai_payload_capture_env_vars
from harness_sdk.plugins.control import ControlPlugin, get_control_registry
Expand Down Expand Up @@ -85,16 +88,20 @@ def instrument(self, app=None, skip_libraries=None, auto_instrument=False):
logger.debug('agent is not initialized, not instrumenting')
return

maybe_set_genai_payload_capture_env_vars()
if any_ai_provider_enabled():
maybe_set_genai_payload_capture_env_vars()

for library_key in SUPPORTED_LIBRARIES:
if library_key in skip_libraries:
logger.debug('not attempting to instrument %s', library_key)
continue
if not is_library_enabled(library_key):
logger.debug('%s not enabled via opt-in env flag, skipping', library_key)
continue
logger.debug("attempting to instrument %s", library_key)
self._instrument(library_key, app, auto_instrument)

if self._should_instrument_generic_contrib():
if is_api_instrumentation_enabled() and self._should_instrument_generic_contrib():
instrument_supported_contrib_without_wrapper(skip_libraries)
else:
logger.debug("Skipping generic contrib fallback instrumentation")
Expand Down
7 changes: 2 additions & 5 deletions src/harness_sdk/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from opentelemetry.sdk.resources import Resource
from harness_sdk import constants
from harness_sdk.config import config_pb2
from harness_sdk.env import is_env_var_present
from harness_sdk.otlp_reporting import (
compression_type_to_otlp_grpc,
compression_type_to_otlp_http,
Expand All @@ -42,11 +43,7 @@ def apply_config(self, agent_config):
self._config = agent_config
self.init_trace_provider()
self.init_propagation()
if (
"HA_ENABLE_CONSOLE_SPAN_EXPORTER" in os.environ
or "AT_ENABLE_CONSOLE_SPAN_EXPORTER" in os.environ
or "TA_ENABLE_CONSOLE_SPAN_EXPORTER" in os.environ
):
if is_env_var_present("ENABLE_CONSOLE_SPAN_EXPORTER"):
self.set_console_span_processor()

def init_trace_provider(self) -> None:
Expand Down
20 changes: 7 additions & 13 deletions src/harness_sdk/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
from harness_sdk.config import config_pb2
from harness_sdk.config.default import DEFAULT, SDK_CONFIG_KEYS
from harness_sdk.config.environment import overwrite_with_environment
from harness_sdk.env import get_env_value
from harness_sdk.otlp_reporting import normalize_reporting_dict
from harness_sdk.custom_logger import get_custom_logger

logger = get_custom_logger(__name__)


def _parse_plugin_env(env_key: str) -> list | None:
raw = os.environ.get(env_key)
def _parse_plugin_env(target_key: str) -> list | None:
"""Parse a comma-separated plugin list, honoring HARNESS_/HA_/AT_/TA_ precedence."""
raw = get_env_value(target_key)
if not raw:
return None
return [name.strip() for name in raw.split(',') if name.strip()]
Expand Down Expand Up @@ -94,19 +96,15 @@ def build_config():

plugins_config = config_dict.pop('plugins', {})

enabled_control_plugins = _parse_plugin_env('HA_CONTROL_PLUGINS')
if enabled_control_plugins is None:
enabled_control_plugins = _parse_plugin_env('AT_CONTROL_PLUGINS')
enabled_control_plugins = _parse_plugin_env('CONTROL_PLUGINS')
if enabled_control_plugins is None:
enabled_control_plugins = _ordered_plugin_names_from_section(
plugins_config.get('control')
)
if not enabled_control_plugins:
enabled_control_plugins = []

enabled_observability_plugins = _parse_plugin_env('HA_OBSERVABILITY_PLUGINS')
if enabled_observability_plugins is None:
enabled_observability_plugins = _parse_plugin_env('AT_OBSERVABILITY_PLUGINS')
enabled_observability_plugins = _parse_plugin_env('OBSERVABILITY_PLUGINS')
if enabled_observability_plugins is None:
enabled_observability_plugins = _ordered_plugin_names_from_section(
plugins_config.get('observability')
Expand Down Expand Up @@ -149,8 +147,4 @@ def read_from_file():


def _config_file_path():
return (
os.environ.get('HA_CONFIG_FILE')
or os.environ.get('AT_CONFIG_FILE')
or os.environ.get('TA_CONFIG_FILE')
)
return get_env_value('CONFIG_FILE')
9 changes: 4 additions & 5 deletions src/harness_sdk/config/environment.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Apply HA_* environment variables to harness-sdk configuration."""
import os

"""Apply harness-sdk environment variables to configuration."""
from google.protobuf import wrappers_pb2

from harness_sdk.config import config_pb2
from harness_sdk.env import get_env_value
from harness_sdk.otlp_reporting import compression_type_to_enum


def _env(name):
"""Prefer HA_ prefix; accept legacy AT_/TA_ for SDK settings during migration."""
return os.environ.get(f"HA_{name}") or os.environ.get(f"AT_{name}") or os.environ.get(f"TA_{name}")
"""Prefer HARNESS_ prefix; accept legacy HA_/AT_/TA_ for SDK settings during migration."""
return get_env_value(name)


def overwrite_with_environment(config, reporting_encoding=None): # pylint: disable=R0912,R0914,R0915
Expand Down
Loading
Loading