Skip to content
Merged
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
112 changes: 112 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# harness-sdk — agent orientation

## What this repo is

A Python SDK that instruments arbitrary Python applications with OpenTelemetry tracing and ships spans to a Harness OTLP ingest endpoint. Two extension points: **control plugins** (policy/blocking) and **observability plugins** (span processors/exporters).

## Local setup

```bash
bash scripts/fetch-vendor.sh
pip install -e ".[dev,anthropic,openai,litellm]"
./scripts/run-unit-tests.sh
```

Integration tests (need Docker):
```bash
cd test/externalServices && docker compose up -d --wait
cd ../..
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.

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 |

## Plugin system

### Entry point groups

| Group | Purpose |
|---|---|
| `harness_sdk_control_plugin` | Policy plugins — can block requests |
| `harness_sdk_observability_plugin` | Span processor plugins |

Built-in observability plugins (auto-loaded unless overridden):
- `builtin_pipeline` — wires OTLP exporter + DB span filter + attribute exclusion
- `builtin_span_attributes` — stamps `service.name` and custom span attributes

### Writing a plugin

**Control plugin** — implement the `ControlPlugin` protocol (`src/harness_sdk/plugins/control.py`):
- `on_init(config)` — called once at load time
- `evaluate(span, url, headers, body, is_grpc) -> ControlResult` — HTTP/gRPC spans
- `evaluate_agent_span(span, body) -> ControlResult` — GenAI spans
- `shutdown()` — called on SDK teardown
- Set `provides_blocking = True` on the class if `evaluate` may return `block=True`

**Observability plugin** — any class with:
- `on_init(config)`
- `create_span_processors(config) -> List[SpanProcessor]`
- `shutdown()`

Register via `pyproject.toml` entry points, then list the name in `HA_OBSERVABILITY_PLUGINS` or `HA_CONTROL_PLUGINS`.

## Key source files

| File | What it does |
|---|---|
| `src/harness_sdk/agent.py` | `Agent` singleton — entry point for all SDK users |
| `src/harness_sdk/agent_init.py` | OTel tracer provider + exporter setup |
| `src/harness_sdk/config/config.py` | Config loading (YAML file → env → protobuf) |
| `src/harness_sdk/config/environment.py` | Env var → protobuf field mapping |
| `src/harness_sdk/plugins/control.py` | `ControlRegistry` singleton + `ControlPlugin` protocol |
| `src/harness_sdk/plugins/loader.py` | Entry-point discovery + ordered plugin loading |
| `src/harness_sdk/plugins/builtin/pipeline.py` | Default OTLP pipeline observability plugin |
| `src/harness_sdk/db_control_span_processor.py` | Filters DB spans through control registry |
| `src/harness_sdk/instrumentation/litellm/` | LiteLLM wraps with pre-call policy evaluation |
| `src/harness_sdk/instrumentation/mcp/` | MCP SDK instrumentation + GenAI attribute mirroring |
| `src/harness_sdk/autoinstrumentation/sitecustomize.py` | Zero-touch init via `PYTHONPATH` |

## Span processor pipeline (default)

```
SamplingSpanProcessor (DbControlSpanProcessor)
└─ ExcludeByAttributeSpanProcessor (drops traceableai.span_type=nospan)
└─ BatchSpanProcessor
└─ OTLPSpanExporter
```

`DbControlSpanProcessor` — filters MySQL/PostgreSQL spans through control plugins.

## Build / vendor

```bash
bash scripts/fetch-vendor.sh # download vendored deps into temporary-vendor/
python -m build --outdir dist # produces wheel + sdist
```

The `scripts/bundle_vendor.py` script is used by CI to embed vendored packages into the wheel so the SDK can be installed in environments without network access.

## Test layout

Tests mirror source: `test/instrumentation/flask/` covers `src/harness_sdk/instrumentation/flask/`. File naming is mixed (`test_*.py` and `*_test.py`) — both are collected by pytest.

Integration tests are gated behind `RUN_SDK_INTEGRATION_TESTS=1` and require Docker services started from `test/externalServices/`.
1 change: 1 addition & 0 deletions CLAUDE.md
18 changes: 8 additions & 10 deletions src/harness_sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ def __new__(cls):

def __init__(self):
if getattr(self, "_initialized", False):
self.is_lambda = False
return
logger.debug('Initializing Agent.')
if not self.is_enabled():
return
self.is_lambda = False
try:
self._config = Config()
self._init = AgentInit(self._config)
Expand All @@ -56,21 +56,19 @@ def __init__(self):
self._init.init_propagation()
load_observability_plugins(self._config)
self._initialized = True
logger.debug("Platform: %s", distro.id())
logger.debug("Platform version: %s", distro.version())
logger.debug('Harness SDK version: %s', __version__)
logger.debug("successfully initialized harness sdk")
if hasattr(os, 'register_at_fork'):
logger.info('Registering after_in_child handler.')
os.register_at_fork(after_in_child=self.post_fork) # pylint:disable=E1101
except Exception as err: # pylint: disable=W0703
logger.error(
'Failed to initialize Agent: exception=%s, stacktrace=%s',
err,
traceback.format_exc(),
)
self.is_lambda = False
logger.debug("Platform: %s", distro.id())
logger.debug("Platform version: %s", distro.version())
logger.debug('Harness SDK version: %s', __version__)
logger.debug("successfully initialized harness sdk")

if hasattr(os, 'register_at_fork'):
logger.info('Registering after_in_child handler.')
os.register_at_fork(after_in_child=self.post_fork) # pylint:disable=E1101

def post_fork(self):
logger.info("In post fork hook")
Expand Down
8 changes: 2 additions & 6 deletions src/harness_sdk/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,8 @@ def __init__(self, agent_config):
logger.debug('Initializing AgentInit object.')
self._config = agent_config

if hasattr(os, 'register_at_fork'):
logger.info('Registering after_in_child handler.')
os.register_at_fork(after_in_child=self.post_fork) # pylint:disable=E1101

def post_fork(self):
self.apply_config(self._config) # pylint:disable=W0212
self.apply_config(self._config)

def apply_config(self, agent_config):
if agent_config:
Expand Down Expand Up @@ -107,7 +103,7 @@ def set_console_span_processor(self) -> None:
simple_export_span_processor = SimpleSpanProcessor(console_span_exporter)
trace.get_tracer_provider().add_span_processor(simple_export_span_processor)

def _init_exporter(self, trace_reporter_type):
def init_exporter(self, trace_reporter_type):
exporter_type = ''
exporter = None
exporter_endpoint = None
Expand Down
10 changes: 6 additions & 4 deletions src/harness_sdk/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ def _filter_sdk_config(file_dict):

class Config: # pylint:disable=R0903
_instance = None
_singleton_lock = __import__('threading').Lock()

def __new__(cls):
if not hasattr(cls, "_instance") or cls._instance is None:
cls._instance = super(Config, cls).__new__(cls)
if getattr(cls, '_instance', None) is None:
with cls._singleton_lock:
if getattr(cls, '_instance', None) is None:
cls._instance = super(Config, cls).__new__(cls)
return cls._instance

def __init__(self):
Expand All @@ -76,8 +79,7 @@ def get_plugin_option(self, plugin_name: str, option: str, default=None, plugin_
def merge_config(base_config, overriding_config):
for key in overriding_config:
if key in base_config and isinstance(base_config[key], dict):
if key in overriding_config:
base_config[key] = merge_config(base_config[key], overriding_config[key])
base_config[key] = merge_config(base_config[key], overriding_config[key])
else:
base_config[key] = overriding_config[key]
return base_config
Expand Down
42 changes: 42 additions & 0 deletions src/harness_sdk/db_control_span_processor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Span processor that applies control plugins to database spans."""
from opentelemetry.sdk.trace import SpanProcessor
from harness_sdk.plugins.control import get_control_registry
from harness_sdk.custom_logger import get_custom_logger

logger = get_custom_logger(__name__)


class DbControlSpanProcessor(SpanProcessor):
"""Filters database spans via control plugins before export."""

DB_SYSTEM_ATTR = "db.system"
DB_MYSQL = "mysql"
DB_POSTGRESQL = "postgresql"

def __init__(self, processor):
self._processor = processor

def on_start(self, span, parent_context=None):
self._processor.on_start(span, parent_context)

def on_end(self, span):
span_attributes = span.attributes or {}
db_system = span_attributes.get(self.DB_SYSTEM_ATTR)

if db_system in [self.DB_MYSQL, self.DB_POSTGRESQL]:
try:
db_statement = span_attributes.get("db.statement", "")
filter_result = get_control_registry().evaluate(span, "", {}, db_statement, False)
if filter_result and filter_result.block:
logger.debug("Filtering out %s span based on control plugin", db_system)
return
except Exception as e: # pylint:disable=W0718
logger.error("Error applying control plugin to %s span: %s", db_system, str(e))

self._processor.on_end(span)

def force_flush(self, timeout_millis=30000):
return self._processor.force_flush(timeout_millis)

def shutdown(self):
return self._processor.shutdown()
12 changes: 6 additions & 6 deletions src/harness_sdk/plugins/builtin/pipeline.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Default OTLP export pipeline with sampling and attribute processors."""
"""Default OTLP export pipeline with db-filter and attribute processors."""
import os
from typing import Any, List

Expand All @@ -8,13 +8,13 @@
from harness_sdk.agent_init import AgentInit
from harness_sdk.custom_logger import get_custom_logger
from harness_sdk.excluded_by_attribute_span_processor import ExcludeByAttributeSpanProcessor
from harness_sdk.sampling_span_processor import SamplingSpanProcessor
from harness_sdk.db_control_span_processor import DbControlSpanProcessor

logger = get_custom_logger(__name__)


class BuiltinPipelinePlugin:
"""Observability plugin that wires exporter + sampling + exclusion processors."""
"""Observability plugin that wires exporter + db-filter + exclusion processors."""

name = "builtin_pipeline"
priority = 100
Expand All @@ -32,7 +32,7 @@ def create_span_processors(self, config: Any) -> List[SpanProcessor]:
self._agent_init.set_console_span_processor()
return []

exporter = self._agent_init._init_exporter( # pylint: disable=protected-access
exporter = self._agent_init.init_exporter(
config.config.reporting.trace_reporter_type
)
if exporter is None:
Expand All @@ -45,8 +45,8 @@ def create_span_processors(self, config: Any) -> List[SpanProcessor]:
attribute_name="traceableai.span_type",
excluded_value="nospan",
)
sampling_processor = SamplingSpanProcessor(filter_processor)
return [sampling_processor]
db_control_processor = DbControlSpanProcessor(filter_processor)
return [db_control_processor]

def shutdown(self) -> None:
pass
Expand Down
6 changes: 5 additions & 1 deletion src/harness_sdk/plugins/control.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Control plugin protocol and registry for request/span policy evaluation."""
from __future__ import annotations

import threading
from dataclasses import dataclass, field
from typing import Any, List, Optional, Protocol, runtime_checkable

Expand Down Expand Up @@ -60,10 +61,13 @@ class ControlRegistry:
"""Singleton registry that chains control plugins (short-circuit on block)."""

_instance: Optional["ControlRegistry"] = None
_singleton_lock: threading.Lock = threading.Lock()

def __new__(cls) -> "ControlRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
with cls._singleton_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __init__(self) -> None:
Expand Down
44 changes: 3 additions & 41 deletions src/harness_sdk/sampling_span_processor.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,4 @@
"""Span processor that applies control plugins to database spans."""
from opentelemetry.sdk.trace import SpanProcessor
from harness_sdk.plugins.control import get_control_registry
from harness_sdk.custom_logger import get_custom_logger
"""Backwards-compatibility shim — use db_control_span_processor.DbControlSpanProcessor."""
from harness_sdk.db_control_span_processor import DbControlSpanProcessor as SamplingSpanProcessor

logger = get_custom_logger(__name__)


class SamplingSpanProcessor(SpanProcessor):
"""Applies control plugins on database spans before export."""

DB_SYSTEM_ATTR = "db.system"
DB_MYSQL = "mysql"
DB_POSTGRESQL = "postgresql"

def __init__(self, processor):
self._processor = processor

def on_start(self, span, parent_context=None):
self._processor.on_start(span, parent_context)

def on_end(self, span):
span_attributes = span.attributes or {}
db_system = span_attributes.get(self.DB_SYSTEM_ATTR)

if db_system in [self.DB_MYSQL, self.DB_POSTGRESQL]:
try:
db_statement = span_attributes.get("db.statement", "")
filter_result = get_control_registry().evaluate(span, "", {}, db_statement, False)
if filter_result and filter_result.block:
logger.debug("Filtering out %s span based on control plugin", db_system)
return
except Exception as e: # pylint:disable=W0718
logger.error("Error applying control plugin to %s span: %s", db_system, str(e))

self._processor.on_end(span)

def force_flush(self, timeout_millis=30000):
return self._processor.force_flush(timeout_millis)

def shutdown(self):
return self._processor.shutdown()
__all__ = ["SamplingSpanProcessor"]
Loading
Loading