From 1088f999ef26a376c9c0f34674876d62a3db63fe Mon Sep 17 00:00:00 2001 From: Sunil Gattupalle Date: Thu, 2 Jul 2026 14:05:24 -0700 Subject: [PATCH 1/2] fix: code review fixes + agent-friendly CLAUDE.md - agent.py: move register_at_fork inside try block so fork handler is only registered on successful init; fix _initialized guard so is_lambda isn't reset on every Agent() call - agent_init.py: remove register_at_fork from AgentInit.__init__ to eliminate duplicate fork hook registrations; expose _init_exporter as public init_exporter - plugins/builtin/pipeline.py: call init_exporter (no longer private); remove AgentInit instantiation that was registering a third fork hook - plugins/control.py: add double-checked locking to ControlRegistry.__new__ for thread-safe singleton construction - config/config.py: add double-checked locking to Config.__new__; use getattr for _instance to handle test teardown that deletes the attr; remove dead inner `if key in overriding_config` check in merge_config - sampling_span_processor.py: replace with backwards-compat shim; rename class to DbControlSpanProcessor in new db_control_span_processor.py - tests: update to use init_exporter and db_control_span_processor module - CLAUDE.md: add agent orientation doc covering env vars, plugin system, key files, pipeline diagram, and build/test instructions Co-Authored-By: Claude Sonnet 4.6 AI-Session-Id: b304dbed-aac0-46f5-821c-a12c4bb7e10d AI-Tool: claude-code AI-Model: unknown --- CLAUDE.md | 112 ++++++++++++++++ src/harness_sdk/agent.py | 18 ++- src/harness_sdk/agent_init.py | 8 +- src/harness_sdk/config/config.py | 10 +- src/harness_sdk/db_control_span_processor.py | 42 ++++++ src/harness_sdk/plugins/builtin/pipeline.py | 12 +- src/harness_sdk/plugins/control.py | 6 +- src/harness_sdk/sampling_span_processor.py | 44 +----- test/db_control_span_processor_test.py | 133 +++++++++++++++++++ test/init/__init__test.py | 6 +- test/init/exporter_config_test.py | 6 +- test/sampling_span_processor_test.py | 2 +- 12 files changed, 324 insertions(+), 75 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/harness_sdk/db_control_span_processor.py create mode 100644 test/db_control_span_processor_test.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0197b06 --- /dev/null +++ b/CLAUDE.md @@ -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/`. diff --git a/src/harness_sdk/agent.py b/src/harness_sdk/agent.py index a06954e..3dcce58 100644 --- a/src/harness_sdk/agent.py +++ b/src/harness_sdk/agent.py @@ -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) @@ -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") diff --git a/src/harness_sdk/agent_init.py b/src/harness_sdk/agent_init.py index 684c48c..2d93797 100644 --- a/src/harness_sdk/agent_init.py +++ b/src/harness_sdk/agent_init.py @@ -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: @@ -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 diff --git a/src/harness_sdk/config/config.py b/src/harness_sdk/config/config.py index bc1c924..4a00d9f 100644 --- a/src/harness_sdk/config/config.py +++ b/src/harness_sdk/config/config.py @@ -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): @@ -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 diff --git a/src/harness_sdk/db_control_span_processor.py b/src/harness_sdk/db_control_span_processor.py new file mode 100644 index 0000000..3f297f5 --- /dev/null +++ b/src/harness_sdk/db_control_span_processor.py @@ -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() diff --git a/src/harness_sdk/plugins/builtin/pipeline.py b/src/harness_sdk/plugins/builtin/pipeline.py index 9579182..46a3f45 100644 --- a/src/harness_sdk/plugins/builtin/pipeline.py +++ b/src/harness_sdk/plugins/builtin/pipeline.py @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/src/harness_sdk/plugins/control.py b/src/harness_sdk/plugins/control.py index a7a6386..f3eae9e 100644 --- a/src/harness_sdk/plugins/control.py +++ b/src/harness_sdk/plugins/control.py @@ -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 @@ -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: diff --git a/src/harness_sdk/sampling_span_processor.py b/src/harness_sdk/sampling_span_processor.py index 53dee51..06aed9b 100644 --- a/src/harness_sdk/sampling_span_processor.py +++ b/src/harness_sdk/sampling_span_processor.py @@ -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"] diff --git a/test/db_control_span_processor_test.py b/test/db_control_span_processor_test.py new file mode 100644 index 0000000..35648e7 --- /dev/null +++ b/test/db_control_span_processor_test.py @@ -0,0 +1,133 @@ +import unittest +from unittest.mock import MagicMock, patch + +from opentelemetry.sdk.trace import TracerProvider +from harness_sdk.db_control_span_processor import DbControlSpanProcessor + + +class TestDbControlSpanProcessor(unittest.TestCase): + def setUp(self): + self.mock_processor = MagicMock() + self.processor = DbControlSpanProcessor(processor=self.mock_processor) + self.tracer_provider = TracerProvider() + self.tracer = self.tracer_provider.get_tracer(__name__) + + self.mock_registry = MagicMock() + self.registry_patcher = patch( + 'harness_sdk.db_control_span_processor.get_control_registry', + return_value=self.mock_registry, + ) + self.registry_patcher.start() + + def tearDown(self): + self.registry_patcher.stop() + + def create_test_span(self, attributes=None): + """Helper method to create a test span with the given attributes.""" + with self.tracer.start_as_current_span("test-span") as span: + if attributes: + for key, value in attributes.items(): + span.set_attribute(key, value) + return span + + def test_on_start_delegates_to_processor(self): + span = self.create_test_span() + parent_context = MagicMock() + + self.processor.on_start(span, parent_context) + + self.mock_processor.on_start.assert_called_once_with(span, parent_context) + + def test_non_db_span_is_passed_through(self): + span = self.create_test_span({"other_attr": "value"}) + + self.processor.on_end(span) + + self.mock_processor.on_end.assert_called_once_with(span) + self.mock_registry.evaluate.assert_not_called() + + def test_mysql_span_passes_registry_filter(self): + span = self.create_test_span({ + "db.system": "mysql", + "db.statement": "SELECT * FROM users" + }) + + mock_filter_result = MagicMock() + mock_filter_result.block = False + self.mock_registry.evaluate.return_value = mock_filter_result + + self.processor.on_end(span) + + self.mock_registry.evaluate.assert_called_once_with( + span, "", {}, "SELECT * FROM users", False + ) + self.mock_processor.on_end.assert_called_once_with(span) + + def test_postgresql_span_passes_registry_filter(self): + span = self.create_test_span({ + "db.system": "postgresql", + "db.statement": "INSERT INTO users VALUES (1, 'test')" + }) + + mock_filter_result = MagicMock() + mock_filter_result.block = False + self.mock_registry.evaluate.return_value = mock_filter_result + + self.processor.on_end(span) + + self.mock_registry.evaluate.assert_called_once_with( + span, "", {}, "INSERT INTO users VALUES (1, 'test')", False + ) + self.mock_processor.on_end.assert_called_once_with(span) + + def test_db_span_blocked_by_registry_filter(self): + span = self.create_test_span({ + "db.system": "mysql", + "db.statement": "SELECT * FROM sensitive_data" + }) + + mock_filter_result = MagicMock() + mock_filter_result.block = True + self.mock_registry.evaluate.return_value = mock_filter_result + + self.processor.on_end(span) + + self.mock_registry.evaluate.assert_called_once() + self.mock_processor.on_end.assert_not_called() + + def test_db_span_without_statement(self): + span = self.create_test_span({ + "db.system": "mysql" + }) + + mock_filter_result = MagicMock() + mock_filter_result.block = False + self.mock_registry.evaluate.return_value = mock_filter_result + + self.processor.on_end(span) + + self.mock_registry.evaluate.assert_called_once_with( + span, "", {}, "", False + ) + self.mock_processor.on_end.assert_called_once_with(span) + + def test_registry_filter_exception_handling(self): + span = self.create_test_span({ + "db.system": "mysql", + "db.statement": "SELECT * FROM users" + }) + + self.mock_registry.evaluate.side_effect = Exception("Test exception") + + self.processor.on_end(span) + + self.mock_processor.on_end.assert_called_once_with(span) + + def test_force_flush_delegates_to_processor(self): + timeout = 5000 + self.processor.force_flush(timeout) + self.mock_processor.force_flush.assert_called_once_with(timeout) + + def test_shutdown_delegates_to_processor(self): + self.processor.shutdown() + self.mock_processor.shutdown.assert_called_once() diff --git a/test/init/__init__test.py b/test/init/__init__test.py index 6c09dac..ce44e41 100644 --- a/test/init/__init__test.py +++ b/test/init/__init__test.py @@ -7,19 +7,19 @@ def test_otlp_http_exporter_can_init(agent): - exporter = agent._init._init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) + exporter = agent._init.init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) exporter.shutdown() assert isinstance(exporter, OTLPHttpSpanExporter) def test_otlp_grpc_exporter_can_init(agent): - exporter = agent._init._init_exporter(config_pb2.TraceReporterType.OTLP) + exporter = agent._init.init_exporter(config_pb2.TraceReporterType.OTLP) exporter.shutdown() assert isinstance(exporter, OTLPGrpcSpanExporter) def test_zipkin_exporter_can_init(agent): # Disable until otel zipkin is updated for newer protobuf pass - #exporter = agent._init._init_exporter(config_pb2.TraceReporterType.ZIPKIN) + #exporter = agent._init.init_exporter(config_pb2.TraceReporterType.ZIPKIN) #exporter.shutdown() # assert isinstance(exporter, ZipkinExporter) diff --git a/test/init/exporter_config_test.py b/test/init/exporter_config_test.py index c0efdc0..d801b48 100644 --- a/test/init/exporter_config_test.py +++ b/test/init/exporter_config_test.py @@ -11,7 +11,7 @@ def test_otlp_http_exporter_appends_traces_path(agent): agent._config.config.reporting.endpoint.value = 'http://collector:4318' agent._config.config.reporting.trace_reporter_type = config_pb2.TraceReporterType.OTLP_HTTP - exporter = agent._init._init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) + exporter = agent._init.init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) try: assert exporter._endpoint == 'http://collector:4318/v1/traces' finally: @@ -24,7 +24,7 @@ def test_otlp_http_exporter_uses_gzip_compression(agent): config_pb2.CompressionType.COMPRESSION_TYPE_GZIP ) - exporter = agent._init._init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) + exporter = agent._init.init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) try: assert isinstance(exporter, OTLPHttpSpanExporter) assert exporter._compression == HttpCompression.Gzip @@ -37,7 +37,7 @@ def test_otlp_http_exporter_warns_on_unsupported_encoding(agent): agent._config.config.reporting.endpoint.value = 'http://collector:4318/v1/traces' with mock.patch('harness_sdk.agent_init.logger') as logger: - exporter = agent._init._init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) + exporter = agent._init.init_exporter(config_pb2.TraceReporterType.OTLP_HTTP) try: logger.warning.assert_called_once() finally: diff --git a/test/sampling_span_processor_test.py b/test/sampling_span_processor_test.py index a85ec0e..03bb4e2 100644 --- a/test/sampling_span_processor_test.py +++ b/test/sampling_span_processor_test.py @@ -14,7 +14,7 @@ def setUp(self): self.mock_registry = MagicMock() self.registry_patcher = patch( - 'harness_sdk.sampling_span_processor.get_control_registry', + 'harness_sdk.db_control_span_processor.get_control_registry', return_value=self.mock_registry, ) self.registry_patcher.start() From c7ac4159174db8ad98d8a9572974a240d42eee87 Mon Sep 17 00:00:00 2001 From: Sunil Gattupalle Date: Thu, 2 Jul 2026 14:06:19 -0700 Subject: [PATCH 2/2] docs: move agent orientation to AGENTS.md, symlink CLAUDE.md Content lives in AGENTS.md (Codex/OpenAI agents convention); CLAUDE.md is a symlink so Claude Code picks it up too. Co-Authored-By: Claude Sonnet 4.6 AI-Session-Id: b304dbed-aac0-46f5-821c-a12c4bb7e10d AI-Tool: claude-code AI-Model: unknown --- AGENTS.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 113 +----------------------------------------------------- 2 files changed, 113 insertions(+), 112 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0197b06 --- /dev/null +++ b/AGENTS.md @@ -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/`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0197b06..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,112 +0,0 @@ -# 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/`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file