# Arthur Observability SDK

## Commands

Skills cover the common flows: `generate-client` (regenerate the gitignored API client — required before tests on a fresh clone), `test-unit`, `test-integration`, `update-docs`.

```bash
./scripts/lint.sh               # black + isort + autoflake + mypy (from arthur-observability-sdk/)
./scripts/build_sdk_wheel.sh    # build wheel (from arthur-observability-sdk/)
cd python && uv run pytest tests -v   # all tests, incl. install/wheel smoke tests
```

## Architecture

The SDK has three layers:

**`Arthur`** (`arthur.py`) — the user-facing entrypoint. Owns the OTel `TracerProvider`, the API client, and the 33 `instrument_*` methods. At least one of `task_id`, `task_name`, or `service_name` must be provided.

**`setup_telemetry`** (`telemetry.py`) — creates a `TracerProvider` with a `BatchSpanProcessor` backed by an OTLP HTTP exporter. Passes `Authorization: Bearer {api_key}` in the exporter headers. Called by `Arthur.__init__` when `enable_telemetry=True`. Registers the provider globally via `trace.set_tracer_provider()`.

**`ArthurAPIClient`** (`_client.py`) — thin wrapper around the generated `arthur_genai_client`. Exposes `get_prompt_by_version`, `get_prompt_by_tag`, `render_prompt`, and `resolve_task_id`. All `ApiException`s are converted to `ArthurAPIError(status_code, detail)`.

## Generated Client (`arthur_genai_client`)

This package is auto-generated from `genai-engine/staging.openapi.json` using OpenAPI Generator v7 and is **gitignored** — it is not committed to the repository. It must be generated before running tests or using the SDK locally. **Do not hand-edit it.** Regenerate whenever the GenAI Engine API changes (or after a fresh clone):

```bash
./scripts/generate_openapi_client.sh generate python
```

`ArthurAPIClient` uses only `PromptsApi` and `TasksApi`. Two calling styles are acceptable depending on the response shape:

**Use `*_with_http_info()` + `response.raw_data`** when the response contains fields that the generated Pydantic models cannot reliably serialise:
```python
response = self._prompts_api.some_method_with_http_info(...)
return json.loads(response.raw_data)
```

**Use the plain variant and access model attributes directly** when the response fields are simple scalars (strings, ints, bools) with no serialisation concerns:
```python
result = self._tasks_api.some_method(...)
return result.some_field
```

The generated Pydantic models have three known serialisation bugs — avoid `.model_dump()` or `.to_dict()` in either case, but direct attribute access on simple fields is safe:
1. **`anyOf` wrappers** (e.g. `Content`, which represents `str | List[OpenAIMessageItem]`) — `model_dump()` returns internal wrapper fields instead of the actual value.
2. **`datetime` fields** — `model_dump()` returns a `datetime` object, not a JSON-serialisable string.
3. **`Set` fields** — `model_dump()` returns a Python `set`, which `json.dumps` cannot serialise.

Use `*_with_http_info()` + `raw_data` whenever the response includes prompts, messages, or any `anyOf`/datetime/set fields. Use the plain variant only for responses that are purely simple scalars.

## Key Patterns

**Task ID resolution** is lazy: if `task_name` is given instead of `task_id`, the name is resolved to a UUID on the first prompt call via `_api_client.resolve_task_id()` and cached in `_resolved_task_id`.

**PROMPT spans** (`get_prompt`, `render_prompt`) are created manually, not via an instrumentor. Because of this they bypass the OpenInference span processor, so `_apply_openinference_context()` must be called explicitly to copy `session.id`, `user.id`, etc. from the OTel context onto the span.

**`render_prompt` span attributes** deliberately distinguish template from result:
- `llm.prompt_template` / `input.value` → original unrendered messages + variable values
- `output.value` → rendered messages with variables substituted

**Framework instrumentation** is all optional. Each `instrument_*` method calls `importlib.import_module()` and raises `ImportError` with the correct `pip install arthur-observability-sdk[{extra}]` hint if the optional dependency is absent.

**`enable_telemetry=False`** skips `TracerProvider` creation entirely — useful when only using prompt management without telemetry.

**Adding a new instrumentor** — four files must be updated together:

1. **`python/src/arthur_observability_sdk/arthur.py`** — add a method after the last `instrument_*` method:
   ```python
   def instrument_my_framework(self) -> Any:
       return self._instrument(
           "openinference-instrumentation-my-framework",  # PyPI package name
           "my-framework",                                 # extras key (used in pip install hint)
           "openinference.instrumentation.my_framework",  # importlib path
           "MyFrameworkInstrumentor",                      # class name
       )
   ```
2. **`python/pyproject.toml`** — two additions:
   - In `[project.optional-dependencies]`: `my-framework = ["openinference-instrumentation-my-framework"]`
   - In the `all` extra list: `"openinference-instrumentation-my-framework"`
3. **`python/README.md`** — add a row to the "Supported instrumentors" table.

`tests/test_instrumentors.py` checks that those places agree on names, but it reads declarations
rather than importing anything, so it cannot see whether `module_path` and `class_name` are real.
Verify those two against the published package:

```bash
pip install "arthur-observability-sdk[my-framework]"
python3 scripts/verify_instrumentor.py --extra my-framework
```

CI runs that per-extra — for changed declarations on every PR, for all of them weekly — via
`.github/workflows/arthur-observability-sdk-instrumentors.yml`. There are no waivers: every
declaration is expected to resolve, so a red job means a real break.

Not every openinference package fits `_instrument()`. Several ship an OTel `SpanProcessor` rather
than a `BaseInstrumentor` and need `tracer_provider.add_span_processor(...)`, which `Arthur` has no
code path for — check which shape the package exports before adding a method for it. Those packages
are **not declared**: they are recorded with a reason in `UNSUPPORTED`
(`scripts/instrumentor_registry.py`), and `test_instrumentors.py` asserts each one is absent from
`arthur.py`, both extras, and the README. Adding support means deleting its entry from that list.
See UP-4874 for the span-processor work.

## Testing

Unit tests mock at the `OTLPSpanExporter` boundary (patch `arthur_observability_sdk.telemetry.OTLPSpanExporter`) or use `InMemorySpanExporter` with a real `TracerProvider` for span-content assertions.

The helper pattern used throughout `test_prompt_management.py` and `test_context_helpers.py`:

```python
def _make_arthur_with_in_memory_spans(task_id=TASK_ID):
    exporter = InMemorySpanExporter()
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    arthur = Arthur(task_id=task_id, enable_telemetry=False)
    arthur._tracer_provider = provider          # inject real provider
    arthur._api_client._prompts_api = MagicMock()
    arthur._api_client._tasks_api = MagicMock()
    return arthur, exporter
```

Mock a generated-client response (set `raw_data` to JSON bytes, matching what `*_with_http_info()` returns):
```python
mock_resp = MagicMock()
mock_resp.raw_data = json.dumps(prompt_data).encode()
arthur._api_client._prompts_api.some_method_with_http_info.return_value = mock_resp
```

`test_install.py` builds the real wheel into a temp venv and runs subprocesses. It also starts an `HTTPServer` on port 0 (OS-assigned) that doubles as both an OTLP collector (`POST /v1/traces`) and a mock OpenAI API (`POST /v1/chat/completions`). These tests run as part of the standard `pytest tests` run.
