diff --git a/CHANGELOG.md b/CHANGELOG.md index 5346a9b3..22fea28e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [1.3.0] - 2026-06-18 + +### Added + +- **Configurable API request timeout** + - `HoneyHive(timeout=...)` now sets the per-request timeout, in seconds, for standard API client calls. You can also set `HH_API_TIMEOUT`; the explicit `timeout=` argument takes precedence. The default remains 5 seconds, so existing clients keep the same timeout unless you opt in. +- **Event lookup by ID** + - The generated sync and async event services now include `GET /v1/events/{event_id}` as `getEvent(..., event_id=...)`, returning `GetEventResponse` / `GetEventResponseEvent` models exported from `honeyhive.models`. +- **Custom tracer export HTTP sessions** + - `HoneyHiveTracer.init(requests_session=...)` and `TracerConfig(requests_session=...)` now accept a caller-owned `requests.Session` for OTLP span export. Use this when you need custom proxies, retries, TLS settings, or connection pooling. The SDK does not close a session that you provide. + +### Compatibility + +- **Self-host: `GET /v1/events/{event_id}` requires backend v1.3.0+** + - Self-hosted deployments must run a HoneyHive backend on version 1.3.0 or greater before calling `GET /v1/events/{event_id}`. Older self-host backends do not expose this route, so event-by-id calls will fail until the backend is upgraded. HoneyHive Cloud users are unaffected. + ## [1.2.1] - 2026-06-11 No customer-facing changes. Internal OpenAPI generator and generated-model cleanup only. diff --git a/openapi/dataplane.yaml b/openapi/dataplane.yaml index b9cbc605..0468985d 100644 --- a/openapi/dataplane.yaml +++ b/openapi/dataplane.yaml @@ -4,7 +4,7 @@ info: license: name: MIT url: https://opensource.org/licenses/MIT - version: 1.1.0 + version: 1.2.1 servers: - url: https://api.honeyhive.ai tags: @@ -335,6 +335,35 @@ paths: '400': description: Bad request (invalid event data or missing required fields) /v1/events/{event_id}: + get: + tags: + - Events + operationId: getEvent + x-cli-name: get + x-ts-sdk-name: get + summary: Get an event by ID + description: | + Retrieve a single event by its unique identifier. The event is fetched + directly from S3/MinIO storage. + parameters: + - in: path + name: event_id + required: true + schema: + type: string + format: uuid + description: The unique identifier of the event to retrieve + responses: + '200': + description: Event retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetEventResponse' + '400': + description: Bad request (invalid event_id) + '404': + description: Event not found put: tags: - Events @@ -2282,6 +2311,72 @@ components: type: http scheme: bearer schemas: + SingleFilter: + type: object + properties: + field: + type: string + operator: + type: string + enum: + - exists + - not exists + - is + - is not + - contains + - not contains + - greater than + - less than + - after + - before + value: + anyOf: + - type: string + - type: number + - type: boolean + - type: 'null' + type: + type: string + enum: + - string + - number + - boolean + - datetime + required: + - field + - operator + - value + - type + Pagination: + type: object + properties: + page: + type: integer + minimum: 1 + limit: + type: integer + minimum: 1 + total: + type: integer + minimum: 0 + total_unfiltered: + type: integer + minimum: 0 + total_pages: + type: integer + minimum: 0 + has_next: + type: boolean + has_prev: + type: boolean + required: + - page + - limit + - total + - total_unfiltered + - total_pages + - has_next + - has_prev CreateChartRequest: type: object properties: @@ -3235,42 +3330,6 @@ components: - dereferenced - message description: Response for DELETE /datasets/{dataset_id}/datapoints/{datapoint_id} - SingleFilter: - type: object - properties: - field: - type: string - operator: - type: string - enum: - - exists - - not exists - - is - - is not - - contains - - not contains - - greater than - - less than - - after - - before - value: - anyOf: - - type: string - - type: number - - type: boolean - - type: 'null' - type: - type: string - enum: - - string - - number - - boolean - - datetime - required: - - field - - operator - - value - - type FiltersArray: type: array items: @@ -3921,6 +3980,14 @@ components: - events - totalEvents description: Response for GET /events + GetEventResponse: + type: object + properties: + event: + $ref: '#/components/schemas/GetEventResponseEvent' + required: + - event + description: Response for GET /events/:event_id — single event payload ExportEventsResponse: type: object properties: @@ -3949,36 +4016,6 @@ components: - event_ids - success description: Response for POST /events/batch - Pagination: - type: object - properties: - page: - type: integer - minimum: 1 - limit: - type: integer - minimum: 1 - total: - type: integer - minimum: 0 - total_unfiltered: - type: integer - minimum: 0 - total_pages: - type: integer - minimum: 0 - has_next: - type: boolean - has_prev: - type: boolean - required: - - page - - limit - - total - - total_unfiltered - - total_pages - - has_next - - has_prev PassingRange: type: object properties: @@ -6179,6 +6216,89 @@ components: required: - $gte - $lte + GetEventResponseEvent: + type: object + properties: + project: + type: string + deprecated: true + description: Project name (ignored by server — project is determined from API key scope) + project_id: + type: string + description: Project ID + source: + type: string + description: Source of the event (e.g., sdk-python) + event_name: + type: string + description: Name of the event + event_type: + type: string + enum: + - model + - tool + - chain + - session + description: Type of event (model, tool, chain, or session) + event_id: + type: string + session_id: + type: string + description: Session this event belongs to + parent_id: + type: string + description: Parent event ID in the trace hierarchy + children_ids: + type: array + items: + type: string + description: Child event IDs in the trace hierarchy + config: + type: object + additionalProperties: {} + description: Configuration used for this event + inputs: + type: object + additionalProperties: {} + description: Input data for the event + outputs: + type: object + additionalProperties: {} + description: Output data from the event + error: + type: + - string + - 'null' + description: Error message if the event failed + start_time: + type: number + description: Event start time as Unix milliseconds + end_time: + type: number + description: Event end time as Unix milliseconds + duration: + type: number + description: Event duration in milliseconds + metadata: + type: object + additionalProperties: {} + description: Arbitrary metadata for the event + feedback: + type: object + additionalProperties: {} + description: Feedback data associated with the event + metrics: + type: object + additionalProperties: {} + description: Metric values computed for the event + user_properties: + type: object + additionalProperties: {} + description: User properties associated with the event + required: + - event_id + additionalProperties: {} + description: Full event object for legacy event creation endpoints EventMetricDataMetadata: type: object properties: diff --git a/scripts/templates/apiconfig_pydantic_2.jinja2 b/scripts/templates/apiconfig_pydantic_2.jinja2 index 08c14044..c8e88a7f 100644 --- a/scripts/templates/apiconfig_pydantic_2.jinja2 +++ b/scripts/templates/apiconfig_pydantic_2.jinja2 @@ -10,6 +10,8 @@ - Removed env_token_name conditional branches (hardcoded to the non-env-token path) - Added _serialize_query_params() helper so List-typed query params are sent as bracket notation (ids[]=a&ids[]=b) instead of bare repeated keys (ids=a&ids=b) + - Added a configurable `timeout` field (defaults to 5.0s, matching httpx's own + default) plumbed through to the httpx clients #} import os from typing import Any, Dict, Optional, Union @@ -31,6 +33,10 @@ class APIConfig(BaseModel): base_path: str = DEFAULT_BASE_URL verify: Union[bool, str] = True access_token: Optional[str] = None + # Request timeout in seconds passed to the underlying httpx client. Defaults + # to 5.0 to match httpx's own default (so behaviour is unchanged unless set). + # Set to None to disable timeouts entirely. + timeout: Optional[float] = 5.0 @classmethod def from_env( diff --git a/scripts/templates/httpx.jinja2 b/scripts/templates/httpx.jinja2 index 4ff75ada..6c87ed81 100644 --- a/scripts/templates/httpx.jinja2 +++ b/scripts/templates/httpx.jinja2 @@ -8,6 +8,8 @@ notation to recognise a single-value list as an array rather than a string. - data.dict() replaced with data.model_dump(exclude_none=True) for Pydantic v2 compatibility and to avoid sending null fields to the API. + - timeout=api_config.timeout passed to the httpx client so the request timeout + is configurable via APIConfig (defaults to 5.0s, matching httpx's default). #} {% if async_client %}async {% endif %}def {{ operation_id }}(api_config_override : Optional[APIConfig] = None{% if params.strip() %}, *, {{ params.rstrip(', ') }}{% endif %}) -> {% if return_type.type is none or return_type.type.converted_type is none %}None{% else %}{{ return_type.type.converted_type}}{% endif %}: api_config = api_config_override if api_config_override else APIConfig() @@ -29,10 +31,10 @@ query_params = {key:value for (key,value) in query_params.items() if value is not None} {% if async_client %} -async with httpx.AsyncClient(base_url=base_path, verify=api_config.verify) as client: +async with httpx.AsyncClient(base_url=base_path, verify=api_config.verify, timeout=api_config.timeout) as client: response = await client.request( {% else %} -with httpx.Client(base_url=base_path, verify=api_config.verify) as client: +with httpx.Client(base_url=base_path, verify=api_config.verify, timeout=api_config.timeout) as client: response = client.request( {% endif %} '{{ method }}', diff --git a/src/honeyhive/__init__.py b/src/honeyhive/__init__.py index a17242ce..c5073cc4 100644 --- a/src/honeyhive/__init__.py +++ b/src/honeyhive/__init__.py @@ -5,7 +5,7 @@ # Version must be defined BEFORE imports to avoid circular import issues # Version must be semver or semver followed by "a" (alpha), "b" (beta), or "rc" # (release candidate) + a number -__version__ = "1.2.1" +__version__ = "1.3.0" # Main API client from .api import HoneyHive diff --git a/src/honeyhive/_generated/api_config.py b/src/honeyhive/_generated/api_config.py index 7a61db15..81f8f667 100644 --- a/src/honeyhive/_generated/api_config.py +++ b/src/honeyhive/_generated/api_config.py @@ -15,6 +15,10 @@ class APIConfig(BaseModel): base_path: str = DEFAULT_BASE_URL verify: Union[bool, str] = True access_token: Optional[str] = None + # Request timeout in seconds passed to the underlying httpx client. Defaults + # to 5.0 to match httpx's own default (so behaviour is unchanged unless set). + # Set to None to disable timeouts entirely. + timeout: Optional[float] = 5.0 @classmethod def from_env( diff --git a/src/honeyhive/_generated/models/GetEventResponse.py b/src/honeyhive/_generated/models/GetEventResponse.py new file mode 100644 index 00000000..aa577619 --- /dev/null +++ b/src/honeyhive/_generated/models/GetEventResponse.py @@ -0,0 +1,23 @@ +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field + +from .GetEventResponseEvent import GetEventResponseEvent + +__all__ = ["GetEventResponse"] + + +class GetEventResponse(BaseModel): + """ + GetEventResponse model + Response for GET /events/:event_id — single event payload + """ + + model_config = { + "populate_by_name": True, + "validate_assignment": True, + "extra": "allow", + "protected_namespaces": (), + } + + event: GetEventResponseEvent = Field(validation_alias="event") diff --git a/src/honeyhive/_generated/models/GetEventResponseEvent.py b/src/honeyhive/_generated/models/GetEventResponseEvent.py new file mode 100644 index 00000000..3123094b --- /dev/null +++ b/src/honeyhive/_generated/models/GetEventResponseEvent.py @@ -0,0 +1,67 @@ +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field + +__all__ = ["GetEventResponseEvent"] + + +class GetEventResponseEvent(BaseModel): + """ + GetEventResponseEvent model + Full event object for legacy event creation endpoints + """ + + model_config = { + "populate_by_name": True, + "validate_assignment": True, + "extra": "allow", + "protected_namespaces": (), + } + + project: Optional[str] = Field(validation_alias="project", default=None) + + project_id: Optional[str] = Field(validation_alias="project_id", default=None) + + source: Optional[str] = Field(validation_alias="source", default=None) + + event_name: Optional[str] = Field(validation_alias="event_name", default=None) + + event_type: Optional[str] = Field(validation_alias="event_type", default=None) + + event_id: str = Field(validation_alias="event_id") + + session_id: Optional[str] = Field(validation_alias="session_id", default=None) + + parent_id: Optional[str] = Field(validation_alias="parent_id", default=None) + + children_ids: Optional[List[str]] = Field( + validation_alias="children_ids", default=None + ) + + config: Optional[Dict[str, Any]] = Field(validation_alias="config", default=None) + + inputs: Optional[Dict[str, Any]] = Field(validation_alias="inputs", default=None) + + outputs: Optional[Dict[str, Any]] = Field(validation_alias="outputs", default=None) + + error: Optional[str] = Field(validation_alias="error", default=None) + + start_time: Optional[float] = Field(validation_alias="start_time", default=None) + + end_time: Optional[float] = Field(validation_alias="end_time", default=None) + + duration: Optional[float] = Field(validation_alias="duration", default=None) + + metadata: Optional[Dict[str, Any]] = Field( + validation_alias="metadata", default=None + ) + + feedback: Optional[Dict[str, Any]] = Field( + validation_alias="feedback", default=None + ) + + metrics: Optional[Dict[str, Any]] = Field(validation_alias="metrics", default=None) + + user_properties: Optional[Dict[str, Any]] = Field( + validation_alias="user_properties", default=None + ) diff --git a/src/honeyhive/_generated/models/__init__.py b/src/honeyhive/_generated/models/__init__.py index b2886122..3539fae0 100644 --- a/src/honeyhive/_generated/models/__init__.py +++ b/src/honeyhive/_generated/models/__init__.py @@ -76,6 +76,8 @@ from .GetDatapointsQuery import * from .GetDatapointsResponse import * from .GetDatasetsResponse import * +from .GetEventResponse import * +from .GetEventResponseEvent import * from .GetEventsQuery import * from .GetEventsResponse import * from .GetEventsSchemaLegacyDateRangeOneOf1 import * diff --git a/src/honeyhive/_generated/services/Charts_service.py b/src/honeyhive/_generated/services/Charts_service.py index 3596d7b2..37e40b08 100644 --- a/src/honeyhive/_generated/services/Charts_service.py +++ b/src/honeyhive/_generated/services/Charts_service.py @@ -18,7 +18,9 @@ def getCharts(api_config_override: Optional[APIConfig] = None) -> GetChartsRespo key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -51,7 +53,9 @@ def createChart( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -85,7 +89,9 @@ def getChart( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -121,7 +127,9 @@ def updateChart( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -155,7 +163,9 @@ def deleteChart( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Configurations_service.py b/src/honeyhive/_generated/services/Configurations_service.py index 1063c56a..68472cde 100644 --- a/src/honeyhive/_generated/services/Configurations_service.py +++ b/src/honeyhive/_generated/services/Configurations_service.py @@ -24,7 +24,9 @@ def getConfigurations( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -61,7 +63,9 @@ def createConfiguration( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -102,7 +106,9 @@ def updateConfiguration( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -140,7 +146,9 @@ def deleteConfiguration( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Datapoints_service.py b/src/honeyhive/_generated/services/Datapoints_service.py index 72eb9e78..b288a618 100644 --- a/src/honeyhive/_generated/services/Datapoints_service.py +++ b/src/honeyhive/_generated/services/Datapoints_service.py @@ -26,7 +26,9 @@ def getDatapoints( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -61,7 +63,9 @@ def createDatapoint( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -101,7 +105,9 @@ def batchCreateDatapoints( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -139,7 +145,9 @@ def getDatapoint( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -175,7 +183,9 @@ def updateDatapoint( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -213,7 +223,9 @@ def deleteDatapoint( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Datasets_service.py b/src/honeyhive/_generated/services/Datasets_service.py index 4940d4ce..7f38257e 100644 --- a/src/honeyhive/_generated/services/Datasets_service.py +++ b/src/honeyhive/_generated/services/Datasets_service.py @@ -23,7 +23,9 @@ def getDatasets( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -56,7 +58,9 @@ def createDataset( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -92,7 +96,9 @@ def updateDatasetLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -128,7 +134,9 @@ def deleteDatasetLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -166,7 +174,9 @@ def updateDataset( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -202,7 +212,9 @@ def deleteDataset( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -240,7 +252,9 @@ def addDatapoints( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -279,7 +293,9 @@ def removeDatapoint( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -319,7 +335,9 @@ def removeDatapointLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Events_service.py b/src/honeyhive/_generated/services/Events_service.py index 6728faf2..5e19abcf 100644 --- a/src/honeyhive/_generated/services/Events_service.py +++ b/src/honeyhive/_generated/services/Events_service.py @@ -20,7 +20,9 @@ def createEventLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -54,7 +56,9 @@ def updateEventLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -88,7 +92,9 @@ def createEvent( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -108,6 +114,41 @@ def createEvent( return PostEventResponse(**body) if body is not None else PostEventResponse() +def getEvent( + api_config_override: Optional[APIConfig] = None, *, event_id: str +) -> GetEventResponse: + api_config = api_config_override if api_config_override else APIConfig() + + base_path = api_config.base_path + path = f"/v1/events/{event_id}" + headers = api_config.get_default_headers() + query_params: Dict[str, Any] = {} + + query_params = { + key: value for (key, value) in query_params.items() if value is not None + } + + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: + response = client.request( + "get", + httpx.URL(path), + headers=headers, + params=_serialize_query_params(query_params), + ) + + if response.status_code != 200: + raise HTTPException( + response.status_code, + f"getEvent failed with status code: {response.status_code}", + ) + else: + body = None if 200 == 204 else response.json() + + return GetEventResponse(**body) if body is not None else GetEventResponse() + + def updateEvent( api_config_override: Optional[APIConfig] = None, *, @@ -125,7 +166,9 @@ def updateEvent( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -159,7 +202,9 @@ def exportEventsLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -193,7 +238,9 @@ def searchEvents( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -227,7 +274,9 @@ def createEventBatch( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -263,7 +312,9 @@ def createModelEventLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -299,7 +350,9 @@ def createEventBatchLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -335,7 +388,9 @@ def createModelEventBatchLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -377,7 +432,9 @@ def getEventsSchemaLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Experiments_service.py b/src/honeyhive/_generated/services/Experiments_service.py index 460aa1ec..db3248ce 100644 --- a/src/honeyhive/_generated/services/Experiments_service.py +++ b/src/honeyhive/_generated/services/Experiments_service.py @@ -40,7 +40,9 @@ def getRuns( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -77,7 +79,9 @@ def createRun( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -117,7 +121,9 @@ def getRunsSchema( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -154,7 +160,9 @@ def getRun( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -194,7 +202,9 @@ def updateRun( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -232,7 +242,9 @@ def deleteRun( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -272,7 +284,9 @@ def getRunSchema( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -313,7 +327,9 @@ def getExperimentRunMetrics( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -357,7 +373,9 @@ def getExperimentSummary( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -401,7 +419,9 @@ def getExperimentResultLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -446,7 +466,9 @@ def getExperimentComparison( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -491,7 +513,9 @@ def getExperimentComparisonLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -542,7 +566,9 @@ def getExperimentCompareEvents( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -595,7 +621,9 @@ def getExperimentCompareEventsLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Metric_Versions_service.py b/src/honeyhive/_generated/services/Metric_Versions_service.py index e3307da4..72f03cda 100644 --- a/src/honeyhive/_generated/services/Metric_Versions_service.py +++ b/src/honeyhive/_generated/services/Metric_Versions_service.py @@ -20,7 +20,9 @@ def getMetricVersions( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -60,7 +62,9 @@ def createMetricVersion( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -101,7 +105,9 @@ def deployMetricVersion( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Metrics_service.py b/src/honeyhive/_generated/services/Metrics_service.py index fd46048e..6c081d05 100644 --- a/src/honeyhive/_generated/services/Metrics_service.py +++ b/src/honeyhive/_generated/services/Metrics_service.py @@ -23,7 +23,9 @@ def getMetrics( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -56,7 +58,9 @@ def createMetric( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -90,7 +94,9 @@ def updateMetricLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -124,7 +130,9 @@ def deleteMetricLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -160,7 +168,9 @@ def updateMetric( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -194,7 +204,9 @@ def deleteMetric( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), @@ -227,7 +239,9 @@ def runMetric( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -261,7 +275,9 @@ def runMetricLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Queues_service.py b/src/honeyhive/_generated/services/Queues_service.py index adea5826..062e5c34 100644 --- a/src/honeyhive/_generated/services/Queues_service.py +++ b/src/honeyhive/_generated/services/Queues_service.py @@ -20,7 +20,9 @@ def getQueues( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -59,7 +61,9 @@ def createQueue( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -97,7 +101,9 @@ def getQueue( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "get", httpx.URL(path), @@ -137,7 +143,9 @@ def updateQueue( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "put", httpx.URL(path), @@ -175,7 +183,9 @@ def deleteQueue( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "delete", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/Sessions_service.py b/src/honeyhive/_generated/services/Sessions_service.py index 8f20c2d6..42b926b9 100644 --- a/src/honeyhive/_generated/services/Sessions_service.py +++ b/src/honeyhive/_generated/services/Sessions_service.py @@ -20,7 +20,9 @@ def startSessionLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -61,7 +63,9 @@ def addSessionTracesLegacy( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -97,7 +101,9 @@ def createSession( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), @@ -138,7 +144,9 @@ def createSessionEventBatch( key: value for (key, value) in query_params.items() if value is not None } - with httpx.Client(base_url=base_path, verify=api_config.verify) as client: + with httpx.Client( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: response = client.request( "post", httpx.URL(path), diff --git a/src/honeyhive/_generated/services/async_Charts_service.py b/src/honeyhive/_generated/services/async_Charts_service.py index 93ed5ba5..bf7f0baa 100644 --- a/src/honeyhive/_generated/services/async_Charts_service.py +++ b/src/honeyhive/_generated/services/async_Charts_service.py @@ -21,7 +21,7 @@ async def getCharts( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -56,7 +56,7 @@ async def createChart( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -92,7 +92,7 @@ async def getChart( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -130,7 +130,7 @@ async def updateChart( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -166,7 +166,7 @@ async def deleteChart( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", diff --git a/src/honeyhive/_generated/services/async_Configurations_service.py b/src/honeyhive/_generated/services/async_Configurations_service.py index 8619a07c..aff39e27 100644 --- a/src/honeyhive/_generated/services/async_Configurations_service.py +++ b/src/honeyhive/_generated/services/async_Configurations_service.py @@ -25,7 +25,7 @@ async def getConfigurations( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -64,7 +64,7 @@ async def createConfiguration( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -107,7 +107,7 @@ async def updateConfiguration( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -147,7 +147,7 @@ async def deleteConfiguration( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", diff --git a/src/honeyhive/_generated/services/async_Datapoints_service.py b/src/honeyhive/_generated/services/async_Datapoints_service.py index 4a1b2ad1..deb8316a 100644 --- a/src/honeyhive/_generated/services/async_Datapoints_service.py +++ b/src/honeyhive/_generated/services/async_Datapoints_service.py @@ -27,7 +27,7 @@ async def getDatapoints( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -64,7 +64,7 @@ async def createDatapoint( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -106,7 +106,7 @@ async def batchCreateDatapoints( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -146,7 +146,7 @@ async def getDatapoint( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -184,7 +184,7 @@ async def updateDatapoint( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -224,7 +224,7 @@ async def deleteDatapoint( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", diff --git a/src/honeyhive/_generated/services/async_Datasets_service.py b/src/honeyhive/_generated/services/async_Datasets_service.py index 8e23c1a9..af26bb82 100644 --- a/src/honeyhive/_generated/services/async_Datasets_service.py +++ b/src/honeyhive/_generated/services/async_Datasets_service.py @@ -24,7 +24,7 @@ async def getDatasets( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -59,7 +59,7 @@ async def createDataset( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -97,7 +97,7 @@ async def updateDatasetLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -135,7 +135,7 @@ async def deleteDatasetLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -175,7 +175,7 @@ async def updateDataset( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -213,7 +213,7 @@ async def deleteDataset( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -253,7 +253,7 @@ async def addDatapoints( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -294,7 +294,7 @@ async def removeDatapoint( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -336,7 +336,7 @@ async def removeDatapointLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", diff --git a/src/honeyhive/_generated/services/async_Events_service.py b/src/honeyhive/_generated/services/async_Events_service.py index 5a0eb11a..6926a9fc 100644 --- a/src/honeyhive/_generated/services/async_Events_service.py +++ b/src/honeyhive/_generated/services/async_Events_service.py @@ -21,7 +21,7 @@ async def createEventLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -57,7 +57,7 @@ async def updateEventLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -93,7 +93,7 @@ async def createEvent( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -114,6 +114,41 @@ async def createEvent( return PostEventResponse(**body) if body is not None else PostEventResponse() +async def getEvent( + api_config_override: Optional[APIConfig] = None, *, event_id: str +) -> GetEventResponse: + api_config = api_config_override if api_config_override else APIConfig() + + base_path = api_config.base_path + path = f"/v1/events/{event_id}" + headers = api_config.get_default_headers() + query_params: Dict[str, Any] = {} + + query_params = { + key: value for (key, value) in query_params.items() if value is not None + } + + async with httpx.AsyncClient( + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout + ) as client: + response = await client.request( + "get", + httpx.URL(path), + headers=headers, + params=_serialize_query_params(query_params), + ) + + if response.status_code != 200: + raise HTTPException( + response.status_code, + f"getEvent failed with status code: {response.status_code}", + ) + else: + body = None if 200 == 204 else response.json() + + return GetEventResponse(**body) if body is not None else GetEventResponse() + + async def updateEvent( api_config_override: Optional[APIConfig] = None, *, @@ -132,7 +167,7 @@ async def updateEvent( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -168,7 +203,7 @@ async def exportEventsLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -204,7 +239,7 @@ async def searchEvents( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -240,7 +275,7 @@ async def createEventBatch( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -278,7 +313,7 @@ async def createModelEventLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -316,7 +351,7 @@ async def createEventBatchLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -354,7 +389,7 @@ async def createModelEventBatchLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -398,7 +433,7 @@ async def getEventsSchemaLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", diff --git a/src/honeyhive/_generated/services/async_Experiments_service.py b/src/honeyhive/_generated/services/async_Experiments_service.py index d5d40618..4cb09360 100644 --- a/src/honeyhive/_generated/services/async_Experiments_service.py +++ b/src/honeyhive/_generated/services/async_Experiments_service.py @@ -41,7 +41,7 @@ async def getRuns( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -80,7 +80,7 @@ async def createRun( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -122,7 +122,7 @@ async def getRunsSchema( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -161,7 +161,7 @@ async def getRun( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -203,7 +203,7 @@ async def updateRun( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -243,7 +243,7 @@ async def deleteRun( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -285,7 +285,7 @@ async def getRunSchema( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -328,7 +328,7 @@ async def getExperimentRunMetrics( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -374,7 +374,7 @@ async def getExperimentSummary( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -420,7 +420,7 @@ async def getExperimentResultLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -467,7 +467,7 @@ async def getExperimentComparison( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -514,7 +514,7 @@ async def getExperimentComparisonLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -567,7 +567,7 @@ async def getExperimentCompareEvents( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -622,7 +622,7 @@ async def getExperimentCompareEventsLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", diff --git a/src/honeyhive/_generated/services/async_Metric_Versions_service.py b/src/honeyhive/_generated/services/async_Metric_Versions_service.py index 718b99c0..19a78ed1 100644 --- a/src/honeyhive/_generated/services/async_Metric_Versions_service.py +++ b/src/honeyhive/_generated/services/async_Metric_Versions_service.py @@ -21,7 +21,7 @@ async def getMetricVersions( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -63,7 +63,7 @@ async def createMetricVersion( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -106,7 +106,7 @@ async def deployMetricVersion( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", diff --git a/src/honeyhive/_generated/services/async_Metrics_service.py b/src/honeyhive/_generated/services/async_Metrics_service.py index 566dccbc..5c149834 100644 --- a/src/honeyhive/_generated/services/async_Metrics_service.py +++ b/src/honeyhive/_generated/services/async_Metrics_service.py @@ -24,7 +24,7 @@ async def getMetrics( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -59,7 +59,7 @@ async def createMetric( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -95,7 +95,7 @@ async def updateMetricLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -131,7 +131,7 @@ async def deleteMetricLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -169,7 +169,7 @@ async def updateMetric( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -205,7 +205,7 @@ async def deleteMetric( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", @@ -240,7 +240,7 @@ async def runMetric( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -276,7 +276,7 @@ async def runMetricLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", diff --git a/src/honeyhive/_generated/services/async_Queues_service.py b/src/honeyhive/_generated/services/async_Queues_service.py index 4a0da843..1d6e4016 100644 --- a/src/honeyhive/_generated/services/async_Queues_service.py +++ b/src/honeyhive/_generated/services/async_Queues_service.py @@ -21,7 +21,7 @@ async def getQueues( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -62,7 +62,7 @@ async def createQueue( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -102,7 +102,7 @@ async def getQueue( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "get", @@ -144,7 +144,7 @@ async def updateQueue( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "put", @@ -184,7 +184,7 @@ async def deleteQueue( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "delete", diff --git a/src/honeyhive/_generated/services/async_Sessions_service.py b/src/honeyhive/_generated/services/async_Sessions_service.py index 3636221c..1c06741e 100644 --- a/src/honeyhive/_generated/services/async_Sessions_service.py +++ b/src/honeyhive/_generated/services/async_Sessions_service.py @@ -21,7 +21,7 @@ async def startSessionLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -64,7 +64,7 @@ async def addSessionTracesLegacy( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -102,7 +102,7 @@ async def createSession( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", @@ -145,7 +145,7 @@ async def createSessionEventBatch( } async with httpx.AsyncClient( - base_url=base_path, verify=api_config.verify + base_url=base_path, verify=api_config.verify, timeout=api_config.timeout ) as client: response = await client.request( "post", diff --git a/src/honeyhive/api/client.py b/src/honeyhive/api/client.py index f6dcd585..34236aaf 100644 --- a/src/honeyhive/api/client.py +++ b/src/honeyhive/api/client.py @@ -172,6 +172,38 @@ def _build_export_timeout() -> httpx.Timeout: EXPORT_TIMEOUT = _build_export_timeout() +def _resolve_api_timeout(explicit: Optional[float]) -> Optional[float]: + """Resolve the general request timeout for the API client. + + Precedence: explicit ``timeout`` argument > ``HH_API_TIMEOUT`` env var. + Returns ``None`` when nothing is configured, signalling the caller to leave + the ``APIConfig`` default (5.0s) untouched. An invalid or non-positive value + warns and falls back to ``None`` (i.e. the default) — httpx treats + ``timeout=0`` as "time out immediately", which would break every request. + """ + if explicit is not None: + if explicit <= 0: + logger.warning("timeout must be positive, got %r; using default", explicit) + return None + return explicit + env_val = os.environ.get("HH_API_TIMEOUT") + if env_val is None: + return None + try: + parsed = float(env_val) + except (ValueError, TypeError): + logger.warning( + "HH_API_TIMEOUT is not a valid number: %r; using default", env_val + ) + return None + if parsed <= 0: + logger.warning( + "HH_API_TIMEOUT must be positive, got %r; using default", env_val + ) + return None + return parsed + + T = TypeVar("T") @@ -2070,7 +2102,9 @@ def __init__( server_url: Deprecated alias for base_url (for backwards compatibility). cp_base_url: Deprecated. Accepted for backwards compatibility but ignored; the SDK now uses a single base_url for all operations. - timeout: Request timeout in seconds (accepted for backwards compat, not used). + timeout: Request timeout in seconds. Falls back to the HH_API_TIMEOUT + env var, then to the SDK default of 5s. Pass a larger value when + fetching large payloads (e.g. datasets.list for many datasets). retry_config: Retry configuration (accepted for backwards compat, not used). rate_limit_calls: Max calls per time window (accepted for backwards compat). rate_limit_window: Time window in seconds (accepted for backwards compat). @@ -2115,11 +2149,19 @@ def __init__( self._verbose = verbose if verbose is not None else False self._tracer_instance = tracer_instance - # Create API config - self._api_config = APIConfig( - base_path=resolved_base_url, - access_token=self._api_key, - ) + # Create API config. The request timeout is resolved from the explicit + # arg > HH_API_TIMEOUT env var; when neither is set we omit the key so + # the APIConfig default (5.0s) is preserved. Note: passing timeout=None + # to HoneyHive() keeps the default (None means "unset" here), whereas + # APIConfig(timeout=None) disables timeouts at the low level. + api_config_kwargs: Dict[str, Any] = { + "base_path": resolved_base_url, + "access_token": self._api_key, + } + resolved_timeout = _resolve_api_timeout(timeout) + if resolved_timeout is not None: + api_config_kwargs["timeout"] = resolved_timeout + self._api_config = APIConfig(**api_config_kwargs) # Initialize API namespaces self.charts = ChartsAPI(self._api_config) diff --git a/src/honeyhive/config/models/tracer.py b/src/honeyhive/config/models/tracer.py index 391a21b5..49ec8510 100644 --- a/src/honeyhive/config/models/tracer.py +++ b/src/honeyhive/config/models/tracer.py @@ -26,7 +26,14 @@ import uuid from typing import Any, Dict, List, Literal, Optional -from pydantic import AliasChoices, BaseModel, Field, field_validator +import requests +from pydantic import ( + AliasChoices, + BaseModel, + Field, + field_serializer, + field_validator, +) from pydantic_settings import SettingsConfigDict from .base import BaseHoneyHiveConfig, _safe_validate_string, _safe_validate_url @@ -105,6 +112,8 @@ class TracerConfig(BaseHoneyHiveConfig): - server_url: Custom HoneyHive server URL (from HH_API_URL env var) - disable_http_tracing: Disable HTTP request tracing (disabled by default) - disable_batch: Disable batch processing of spans + - requests_session: Custom requests.Session for OTLP span export + (caller-owned; not closed by the SDK on shutdown) Example: >>> config = TracerConfig( @@ -257,6 +266,17 @@ class TracerConfig(BaseHoneyHiveConfig): examples=[60.0, 120.0, 300.0], ) + # HTTP session configuration + requests_session: Optional[requests.Session] = Field( # type: ignore[call-overload] + None, + description=( + "Custom requests.Session for OTLP span export HTTP connections, " + "e.g. with custom proxies, retries, or TLS settings. The caller " + "owns the session; the SDK will not close it on shutdown. When " + "unset, the SDK creates its own connection-pooled session." + ), + ) + # Session-related fields (for hybrid approach) session_id: Optional[str] = Field( # type: ignore[call-overload] None, @@ -303,6 +323,24 @@ class TracerConfig(BaseHoneyHiveConfig): case_sensitive=False, ) + @field_serializer("requests_session", when_used="json") + def _serialize_requests_session( + self, value: Optional[requests.Session] + ) -> Optional[str]: + """Serialize the session as a placeholder in JSON mode. + + Session objects aren't JSON-serializable and would otherwise raise + PydanticSerializationError. Python-mode model_dump() is unaffected — + config merging relies on it to pass the session through by reference. + + Args: + value: The configured session, if any + + Returns: + A placeholder string when a session is set, otherwise None + """ + return "" if value is not None else None + @field_validator("server_url", mode="before") @classmethod def validate_server_url(cls, v: Any) -> str: diff --git a/src/honeyhive/tracer/core/base.py b/src/honeyhive/tracer/core/base.py index fa69c30b..8c1e2a0a 100644 --- a/src/honeyhive/tracer/core/base.py +++ b/src/honeyhive/tracer/core/base.py @@ -21,6 +21,7 @@ import warnings from typing import Any, Dict, Optional, Self, Union +import requests from opentelemetry.trace import INVALID_SPAN_CONTEXT, SpanKind from ...api.client import HoneyHive @@ -150,6 +151,7 @@ def __init__( datapoint_id: Union[Optional[str], _ExplicitType] = _EXPLICIT, link_carrier: Union[Optional[Dict[str, Any]], _ExplicitType] = _EXPLICIT, test_mode: Union[bool, _ExplicitType] = _EXPLICIT, + requests_session: Union[Optional[requests.Session], _ExplicitType] = _EXPLICIT, **kwargs: Any, ) -> None: """Initialize the HoneyHive tracer using dynamic configuration merging. @@ -170,6 +172,10 @@ def __init__( :type session_config: Optional[SessionConfig] :param evaluation_config: Evaluation-specific configuration :type evaluation_config: Optional[EvaluationConfig] + :param requests_session: Custom requests.Session for OTLP span export + HTTP connections (e.g. custom proxies, retries, or TLS). The caller + owns the session; the SDK will not close it on shutdown. + :type requests_session: Optional[requests.Session] """ # Multi-instance architecture uses safe_log() for all logging # No direct logger assignment needed - safe_log handles per-instance logging @@ -196,6 +202,7 @@ def __init__( "datapoint_id": datapoint_id, "link_carrier": link_carrier, "test_mode": test_mode, + "requests_session": requests_session, } # Only include explicitly provided parameters (not sentinel values) @@ -536,7 +543,9 @@ def init( config: Pydantic tracer configuration session_config: Session-specific configuration evaluation_config: Evaluation-specific configuration - **kwargs: Backward-compatible parameters + **kwargs: Backward-compatible parameters (e.g. api_key, project, + requests_session — a caller-owned requests.Session used for + OTLP span export and never closed by the SDK) Returns: Initialized HoneyHive tracer instance diff --git a/src/honeyhive/tracer/instrumentation/initialization.py b/src/honeyhive/tracer/instrumentation/initialization.py index 5821b0a7..a47cba32 100644 --- a/src/honeyhive/tracer/instrumentation/initialization.py +++ b/src/honeyhive/tracer/instrumentation/initialization.py @@ -818,6 +818,15 @@ def _create_otlp_exporter(tracer_instance: Any) -> Optional[Any]: # Use custom exporter with optimized connection pooling # Default protocol is http/json (JSON exporter) per SDK configuration + + # Only forward a user-provided session: passing session=None would + # make HoneyHiveOTLPExporter skip its optimized pooled session + # (it gates on the presence of the "session" kwarg) + custom_session = getattr(tracer_instance.config, "requests_session", None) + session_kwargs: Dict[str, Any] = ( + {"session": custom_session} if custom_session is not None else {} + ) + otlp_exporter = HoneyHiveOTLPExporter( tracer_instance=tracer_instance, session_config=session_config, @@ -832,6 +841,7 @@ def _create_otlp_exporter(tracer_instance: Any) -> Optional[Any]: "hh-client-package": "honeyhive", }, timeout=30.0, # 30 second timeout for exports + **session_kwargs, ) safe_log(tracer_instance, "info", "OTLP exporter created successfully") diff --git a/src/honeyhive/tracer/processing/otlp_exporter.py b/src/honeyhive/tracer/processing/otlp_exporter.py index 501aec1c..656c7c1a 100644 --- a/src/honeyhive/tracer/processing/otlp_exporter.py +++ b/src/honeyhive/tracer/processing/otlp_exporter.py @@ -65,6 +65,8 @@ def __init__( self.endpoint = endpoint.rstrip("/") # Copy headers to avoid modifying the original dict self.headers = dict(headers) if headers else {} + # Only close sessions we created; provided sessions stay caller-owned + self._owns_session = session is None self.session = session or requests.Session() self.timeout = timeout self.tracer_instance = tracer_instance @@ -351,7 +353,7 @@ def shutdown(self) -> None: if self._is_shutdown: return self._is_shutdown = True - if self.session: + if self.session and self._owns_session: self.session.close() @@ -394,6 +396,8 @@ def __init__( self.use_optimized_session = use_optimized_session self.protocol = protocol.lower() self._session: Optional[requests.Session] = None + # Only close sessions we created; provided sessions stay caller-owned + self._owns_session = False self._is_shutdown = False self._use_json = self.protocol == "http/json" self._otlp_exporter: Union[OTLPSpanExporter, OTLPJSONExporter] @@ -404,6 +408,7 @@ def __init__( self._session = create_optimized_otlp_session( config=self.session_config, tracer_instance=tracer_instance ) + self._owns_session = True kwargs["session"] = self._session safe_log( @@ -585,6 +590,10 @@ def shutdown(self) -> None: self._is_shutdown = True self._otlp_exporter.shutdown() + # The inner exporter only closes sessions it created itself, so the + # optimized session created above must be closed here + if self._session and self._owns_session: + self._session.close() safe_log( self.tracer_instance, "debug", "HoneyHiveOTLPExporter shutdown completed" ) diff --git a/tests/compatibility_matrix/test_openinference_mcp.py b/tests/compatibility_matrix/test_openinference_mcp.py index b9dc887a..e889c523 100644 --- a/tests/compatibility_matrix/test_openinference_mcp.py +++ b/tests/compatibility_matrix/test_openinference_mcp.py @@ -432,32 +432,6 @@ def test_mcp_instrumentor_version_compatibility(self): assert minor >= 3 # Minimum version 1.3.0 -@pytest.mark.integration -class TestMCPRealAPIIntegration: - """Real API integration tests for MCP instrumentor (requires MCP server setup).""" - - @pytest.fixture(autouse=True) - def check_mcp_server_available(self): - """Check if MCP server is available for real integration testing.""" - # This would check for actual MCP server availability - # For now, we'll skip unless explicitly enabled - if not os.getenv("MCP_INTEGRATION_TEST_ENABLED"): - pytest.skip( - "Real MCP integration tests disabled. Set MCP_INTEGRATION_TEST_ENABLED=1 to enable." - ) - - def test_real_mcp_client_server_tracing(self): - """Test real MCP client-server communication tracing.""" - # This test would require actual MCP client/server setup - # Implementation would depend on specific MCP server being tested - pytest.skip("Real MCP integration test requires MCP server setup") - - def test_mcp_tool_execution_tracing(self): - """Test tracing of actual MCP tool executions.""" - # This test would trace real MCP tool calls - pytest.skip("Real MCP tool execution test requires MCP server setup") - - class TestMCPInstrumentorDocumentation: """Test that MCP instrumentor integration meets documentation requirements.""" diff --git a/tests/integration/api/test_datapoints_api.py b/tests/integration/api/test_datapoints_api.py index 672a2815..a9f73192 100644 --- a/tests/integration/api/test_datapoints_api.py +++ b/tests/integration/api/test_datapoints_api.py @@ -4,8 +4,6 @@ import uuid from typing import Any -import pytest - from honeyhive.models import ( CreateDatapointRequest, CreateDatapointResponse, @@ -174,9 +172,3 @@ def test_delete_datapoint( assert isinstance(response, DeleteDatapointResponse) # Assert response.deleted is True or response.deletedCount >= 1 assert response.deleted is True or getattr(response, "deletedCount", 0) >= 1 - - def test_bulk_operations( - self, integration_client: Any, integration_project_name: str - ) -> None: - """Test bulk create/update/delete, verify all operations.""" - pytest.skip("DatapointsAPI bulk operations may not be implemented yet") diff --git a/tests/integration/api/test_experiments_api.py b/tests/integration/api/test_experiments_api.py index 87b481af..f6e4042a 100644 --- a/tests/integration/api/test_experiments_api.py +++ b/tests/integration/api/test_experiments_api.py @@ -4,8 +4,6 @@ import uuid from typing import Any -import pytest - from honeyhive.models import PostExperimentRunRequest @@ -62,37 +60,38 @@ def test_get_run( if run_name_attr: assert run_name_attr == run_name - @pytest.mark.skip( - reason="Backend: list_runs() without filters returns empty; needs investigation into project-scoping and pagination defaults" - ) def test_list_runs( self, integration_client: Any, integration_project_name: str ) -> None: - """Test run listing, filter by project, pagination.""" + """Test run listing returns created runs with pagination metadata.""" test_id = str(uuid.uuid4())[:8] - for i in range(2): - run_request = PostExperimentRunRequest( - name=f"test_list_run_{test_id}_{i}", - configuration={"model": "gpt-4"}, + created_run_ids = [] + try: + for i in range(2): + run_request = PostExperimentRunRequest( + name=f"test_list_run_{test_id}_{i}", + configuration={"model": "gpt-4"}, + ) + response = integration_client.experiments.create_run(run_request) + created_run_ids.append(response.run_id) + + time.sleep(2) + + # GetExperimentRunsResponse exposes runs as `evaluations` + # plus a `pagination` envelope. + runs_response = integration_client.experiments.list_runs( + name=f"test_list_run_{test_id}" ) - integration_client.experiments.create_run(run_request) - - time.sleep(2) - - runs_response = integration_client.experiments.list_runs() - assert runs_response is not None - runs = runs_response.runs if hasattr(runs_response, "runs") else [] - assert isinstance(runs, list) - assert len(runs) >= 2 - - @pytest.mark.skip(reason="ExperimentsAPI.run_experiment() requires complex setup") - def test_run_experiment( - self, integration_client: Any, integration_project_name: str - ) -> None: - """Test async experiment execution, verify completion status.""" - pytest.skip( - "ExperimentsAPI.run_experiment() requires complex setup " - "with dataset and metrics" - ) + assert runs_response is not None + assert isinstance(runs_response.evaluations, list) + listed_names = {run.name for run in runs_response.evaluations} + assert listed_names == { + f"test_list_run_{test_id}_0", + f"test_list_run_{test_id}_1", + } + assert runs_response.pagination.total >= 2 + finally: + for run_id in created_run_ids: + integration_client.experiments.delete_run(run_id) diff --git a/tests/integration/api/test_metrics_api.py b/tests/integration/api/test_metrics_api.py index d18253b0..152e6b8c 100644 --- a/tests/integration/api/test_metrics_api.py +++ b/tests/integration/api/test_metrics_api.py @@ -3,82 +3,74 @@ import uuid from typing import Any -import pytest - from honeyhive.models import CreateMetricRequest, CreateMetricResponse, MetricItem -class TestMetricsAPI: - """Test MetricsAPI CRUD and compute operations.""" +def _python_metric_request(metric_name: str, description: str) -> CreateMetricRequest: + """Minimal valid PYTHON metric request. - @pytest.mark.skip( - reason="Backend Issue: createMetric endpoint returns 400 Bad Request error" + The backend requires the uppercase ``PYTHON``/``LLM``/``HUMAN``/``COMPOSITE`` + type enum and a numeric ``scale`` whenever ``return_type="float"``. + """ + return CreateMetricRequest( + name=metric_name, + type="PYTHON", + criteria="def evaluate(generation, metadata):\n return len(generation)", + description=description, + return_type="float", + scale=100, ) + + +class TestMetricsAPI: + """Test MetricsAPI CRUD operations.""" + def test_create_metric( self, integration_client: Any, integration_project_name: str ) -> None: - """Test custom metric creation with formula/config, verify backend.""" + """Test custom metric creation, verify backend response.""" test_id = str(uuid.uuid4())[:8] metric_name = f"test_metric_{test_id}" - metric_request = CreateMetricRequest( - name=metric_name, - type="python", - criteria="def evaluate(generation, metadata):\n return len(generation)", - description=f"Test metric {test_id}", - return_type="float", + metric = integration_client.metrics.create( + _python_metric_request(metric_name, f"Test metric {test_id}") ) - metric = integration_client.metrics.create(metric_request) + try: + # POST /metrics returns {inserted, metric_id}, not the full metric. + assert isinstance(metric, CreateMetricResponse) + assert metric.inserted is True + assert metric.metric_id + finally: + integration_client.metrics.delete(metric.metric_id) - assert isinstance(metric, CreateMetricResponse) - assert metric.name == metric_name - assert metric.type == "python" - assert metric.description == f"Test metric {test_id}" - - @pytest.mark.skip( - reason="Backend Issue: createMetric endpoint returns 400 Bad Request error (blocks retrieval test)" - ) def test_get_metric( self, integration_client: Any, integration_project_name: str ) -> None: - """Test metric retrieval by ID/name, test 404, verify metric definition.""" + """Test metric retrieval by ID, verify metric definition.""" test_id = str(uuid.uuid4())[:8] metric_name = f"test_get_metric_{test_id}" - metric_request = CreateMetricRequest( - name=metric_name, - type="python", - criteria="def evaluate(generation, metadata):\n return 1.0", - description="Test metric for retrieval", - return_type="float", + created_metric = integration_client.metrics.create( + _python_metric_request(metric_name, "Test metric for retrieval") ) - created_metric = integration_client.metrics.create(metric_request) - - assert isinstance(created_metric, CreateMetricResponse) - metric_id = getattr( - created_metric, "id", getattr(created_metric, "metric_id", None) - ) - if not metric_id: - pytest.skip( - "Metric creation didn't return ID - backend may not support retrieval" + try: + # get_metric filters GET /v1/metrics server-side by id. + metrics = integration_client.metrics.get_metric(created_metric.metric_id) + assert isinstance(metrics, list) + assert len(metrics) == 1, ( + f"Expected exactly the created metric, got {len(metrics)}" ) - return - - # v1 API doesn't have get_metric by ID - use list and filter - metrics = integration_client.metrics.list(name=metric_name) - assert isinstance(metrics, list) - retrieved_metric = None - for m in metrics: - if m.name == metric_name: - retrieved_metric = m - break + retrieved_metric = metrics[0] - assert retrieved_metric is not None - assert retrieved_metric.name == metric_name - assert retrieved_metric.type == "python" - assert retrieved_metric.description == "Test metric for retrieval" + assert retrieved_metric.id == created_metric.metric_id + assert retrieved_metric.name == metric_name + assert retrieved_metric.type == "PYTHON" + assert retrieved_metric.description == "Test metric for retrieval" + assert retrieved_metric.return_type == "float" + finally: + integration_client.metrics.delete(created_metric.metric_id) def test_list_metrics( self, integration_client: Any, integration_project_name: str @@ -88,12 +80,3 @@ def test_list_metrics( assert isinstance(metrics, list) assert all(isinstance(metric, MetricItem) for metric in metrics) - - def test_compute_metric( - self, integration_client: Any, integration_project_name: str - ) -> None: - """Test metric computation on event(s), verify results accuracy.""" - pytest.skip( - "MetricsAPI.compute_metric() requires event_id " - "and may not be fully implemented" - ) diff --git a/tests/integration/ci_known_failures.txt b/tests/integration/ci_known_failures.txt deleted file mode 100644 index bde61d79..00000000 --- a/tests/integration/ci_known_failures.txt +++ /dev/null @@ -1,36 +0,0 @@ -# Known failing integration tests (CI temporary skiplist). -# Originally generated 2026-02-25; pruned 2026-04-21 (HHAI-4916) after typing -# EventExportResponse.events as List[LegacyEvent] and fixing test-side -# response-shape regressions. Pruned again 2026-04-22 (HHAI-4942) after -# HHAI-4935 restored native-type preservation in the OTLP JSON exporter, -# unblocking 7 otel_backend / lifecycle / concurrency tests that assert -# on native-type attribute round-trip. -# Remove entries as fixes land. -# -# Each remaining entry is annotated inline with the actual failure mode -# observed against testing-dp-1 on 2026-04-22. The "bucket" names below -# are now purely navigational — the real source of truth is per-entry. -# -# Remaining buckets: -# * enrich_span attribute loss on error-raising spans — attrs set via -# `enrich_span({...})` inside a function that raises never make it -# onto the exported span (test.unique_id lookup fails) -# * Session-id override routing — honeyhive.session_id attribute -# override routes events to a different session than the one the -# test queries under - -# enrich_span attribute loss on error-raising spans — OTLP export -# succeeds with HTTP 200, but attributes set via `enrich_span({...})` -# inside the function body are not on the exported span when the -# function raises. Only the SDK's own honeyhive_error*/honeyhive_duration_ms -# attrs persist, so lookup by test.unique_id yields no event. -tests/integration/test_otel_backend_verification_integration.py::TestOTELBackendVerificationIntegration::test_error_spans_backend_verification - -# Session-id override routing — the span is exported with -# honeyhive.session_id=, which the backend routes -# to that session's events, but verify_tracer_span queries under -# integration_tracer.session_id (the SDK-created session). The "not -# found after 10 attempts" failure is a test/routing mismatch, not a -# stringification issue. -tests/integration/test_otel_otlp_export_integration.py::TestOTELOTLPExportIntegration::test_otlp_export_with_backend_verification - diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 72c47718..998aeac9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,8 +13,7 @@ import os import sys import time -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set +from typing import Any, Callable, Dict, List, Optional import pytest from opentelemetry import context, trace @@ -100,34 +99,6 @@ def pytest_collection_modifyitems(config: Any, items: Any) -> None: if "real_api" in item.keywords: item.add_marker(skip_no_api_key) - # Optionally skip known failing integration tests in CI while we - # progressively restore them. - skip_known_failures = os.getenv("HH_INTEGRATION_SKIP_KNOWN_FAILURES", "").lower() - if skip_known_failures in {"1", "true", "yes", "on"}: - default_skiplist = Path(__file__).resolve().parent / "ci_known_failures.txt" - skiplist_path = Path( - os.getenv("HH_INTEGRATION_KNOWN_FAILURES_FILE", str(default_skiplist)) - ) - if not skiplist_path.is_absolute(): - skiplist_path = Path(__file__).resolve().parents[2] / skiplist_path - - if skiplist_path.exists(): - known_failures: Set[str] = { - line.strip() - for line in skiplist_path.read_text(encoding="utf-8").splitlines() - if line.strip() and not line.startswith("#") - } - if known_failures: - skip_known_failure = pytest.mark.skip( - reason=( - "Temporarily skipped known failing integration test in CI; " - "see tests/integration/ci_known_failures.txt" - ) - ) - for item in items: - if item.nodeid in known_failures: - item.add_marker(skip_known_failure) - @pytest.fixture(scope="session") def api_key() -> Optional[str]: @@ -534,6 +505,8 @@ def fetch_session_events( project: Optional[str] = None, max_retries: int = 10, retry_delay: float = 5.0, + min_events: int = 1, + predicate: Optional[Callable[[List[LegacyEvent]], bool]] = None, ) -> List[LegacyEvent]: """Fetch events for a session from HoneyHive API (Data Plane only). @@ -547,6 +520,14 @@ def fetch_session_events( project: Project name (defaults to HH_PROJECT env var). max_retries: Number of times to retry if no events found. retry_delay: Seconds to wait between retries. + min_events: Keep retrying until at least this many events are + ingested. The session-start event lands before child spans, + so callers asserting on child events must wait for more than + the default of 1 or they race the ingestion pipeline. If the + count is never reached, the last (partial) fetch is returned. + predicate: Optional extra wait condition on the fetched events + (e.g. "an event with this exact name exists"). Combined with + min_events; on timeout the last (partial) fetch is returned. Returns: List of ``LegacyEvent`` Pydantic models from @@ -570,6 +551,7 @@ def fetch_session_events( client = HoneyHive(api_key=hh_api_key, base_url=dp_url) + last_events: List[LegacyEvent] = [] for attempt in range(max_retries): try: response = client.events.get_by_session_id( @@ -578,8 +560,11 @@ def fetch_session_events( limit=100, ) - if response.events and len(response.events) > 0: - return response.events + last_events = response.events or [] + if len(last_events) >= min_events and ( + predicate is None or predicate(last_events) + ): + return last_events except Exception: if attempt == max_retries - 1: @@ -588,7 +573,9 @@ def fetch_session_events( # Wait before retry (events may not be ingested yet) time.sleep(retry_delay) - return [] + # min_events never reached — return what did ingest so callers' + # assertion messages reflect the partial state instead of 0. + return last_events def verify_session_logged( @@ -820,7 +807,8 @@ def fetch_events() -> Callable[..., List[LegacyEvent]]: """Fixture providing the :func:`fetch_session_events` helper. Returns a callable with signature - ``(session_id, project=None, max_retries=10, retry_delay=5.0) -> List[LegacyEvent]``. + ``(session_id, project=None, max_retries=10, retry_delay=5.0, + min_events=1, predicate=None) -> List[LegacyEvent]``. """ return fetch_session_events diff --git a/tests/integration/test_events_fixes_integration.py b/tests/integration/test_events_fixes_integration.py index f21f277a..63b4f028 100644 --- a/tests/integration/test_events_fixes_integration.py +++ b/tests/integration/test_events_fixes_integration.py @@ -62,9 +62,6 @@ def tracer(project_name: str) -> HoneyHiveTracer: class TestEventOrderingIntegration: """Integration tests for event ordering in get_by_session_id.""" - @pytest.mark.skip( - reason="Known CI failure: not all child events ingested in time (HHAI-4320)" - ) def test_get_by_session_id_returns_chronological_order( self, api_client: HoneyHive, @@ -100,33 +97,39 @@ def third_operation() -> str: if hasattr(tracer, "flush"): tracer.flush() - # Fetch events using helper with retry logic + # Fetch events using helper with retry logic. Wait for all four + # events (session start + 3 operations) — the session event lands + # first, so a bare existence check would race child ingestion. events = fetch_events( session_id=session_id, project=project_name, max_retries=10, retry_delay=3.0, + min_events=4, ) # Verify we got events - assert len(events) >= 3, f"Expected at least 3 events, got {len(events)}" + assert len(events) >= 4, f"Expected at least 4 events, got {len(events)}" # Filter to our operation events. `events` is List[LegacyEvent]. operation_events = [ e for e in events if (e.event_name or "").endswith("_operation") ] + event_names = [e.event_name or "" for e in operation_events] + assert len(operation_events) == 3, ( + f"Expected 3 operation events, got {len(operation_events)}: {event_names}" + ) - if len(operation_events) >= 3: - # Verify they are in chronological order (first should come before second, etc.) - event_names = [e.event_name or "" for e in operation_events] - # Check that events with earlier timestamps come first - for i in range(len(operation_events) - 1): - current_time = operation_events[i].start_time or 0 - next_time = operation_events[i + 1].start_time or 0 - if current_time and next_time: - assert current_time <= next_time, ( - f"Events not in chronological order: {event_names}" - ) + # Verify they are returned in chronological order + for i in range(len(operation_events) - 1): + current_time = operation_events[i].start_time or 0 + next_time = operation_events[i + 1].start_time or 0 + assert current_time and next_time, ( + f"Operation events missing start_time: {event_names}" + ) + assert current_time <= next_time, ( + f"Events not in chronological order: {event_names}" + ) class TestProjectDeprecationIntegration: diff --git a/tests/integration/test_experiments_run_comparison.py b/tests/integration/test_experiments_run_comparison.py index 76fca5e8..c8672f89 100644 --- a/tests/integration/test_experiments_run_comparison.py +++ b/tests/integration/test_experiments_run_comparison.py @@ -636,8 +636,13 @@ def _improved_impl(datapoint: Dict[str, Any]) -> Dict[str, Any]: # ----------- Matrix coverage: external × server-side ---------------- - @pytest.mark.skip( - reason="HHAI-5345: enrich PUT races ingestion pipeline — un-skip when fixed" + @pytest.mark.xfail( + strict=False, + reason=( + "HHAI-5662: metric_deltas can come back empty when the evaluation " + "pipeline is cold — scores land on chain spans but aren't yet " + "visible to compare_runs" + ), ) def test_compare_runs_with_server_side_metric_external_dataset( self, @@ -728,8 +733,13 @@ def test_compare_runs_with_server_side_metric_external_dataset( # ----------- Matrix coverage: managed × server-side ----------------- - @pytest.mark.skip( - reason="HHAI-5345: enrich PUT races ingestion pipeline — un-skip when fixed" + @pytest.mark.xfail( + strict=False, + reason=( + "HHAI-5662: metric_deltas can come back empty when the evaluation " + "pipeline is cold — scores land on chain spans but aren't yet " + "visible to compare_runs" + ), ) def test_compare_runs_with_server_side_metric_managed_dataset( self, diff --git a/tests/integration/test_otel_backend_verification_integration.py b/tests/integration/test_otel_backend_verification_integration.py index ca681a64..31584176 100644 --- a/tests/integration/test_otel_backend_verification_integration.py +++ b/tests/integration/test_otel_backend_verification_integration.py @@ -279,7 +279,7 @@ def test_high_cardinality_attributes_backend_verification( def test_error_spans_backend_verification( self, tracer_factory: Any, - integration_client: Any, + fetch_events: Any, real_project: Any, real_source: Any, ) -> None: @@ -317,19 +317,25 @@ def operation_that_fails() -> str: with pytest.raises(ValueError, match="Intentional test error"): operation_that_fails() - # Allow time for export and processing - time.sleep(5.0) - try: - # Verify error event using centralized backend verification - - error_event = verify_span_export( - client=integration_client, - project=real_project, + # Retrieve the decorator's _error span by exact event name. + # Lookup by the enriched test.unique_id is not possible here: + # attributes set via enrich_span inside a raising function never + # reach the exported span (HHAI-5661). + events = fetch_events( session_id=test_tracer.session_id, - unique_identifier=unique_id, - expected_event_name=error_event_name, - debug_content=True, # Enable verbose debugging to see what's in backend + project=real_project, + retry_delay=3.0, + predicate=lambda evs: any( + e.event_name == error_event_name for e in evs + ), + ) + error_event = next( + (e for e in events if e.event_name == error_event_name), None + ) + assert error_event is not None, ( + f"Error event '{error_event_name}' not found in backend; " + f"got events: {[e.event_name for e in events]}" ) # Verify basic event properties diff --git a/tests/integration/test_otel_otlp_export_integration.py b/tests/integration/test_otel_otlp_export_integration.py index 1310a9b7..991c342f 100644 --- a/tests/integration/test_otel_otlp_export_integration.py +++ b/tests/integration/test_otel_otlp_export_integration.py @@ -255,12 +255,14 @@ def test_otlp_export_with_backend_verification( # ✅ STANDARD PATTERN: Use verify_tracer_span for span creation # + backend verification # Override session_id with API-created session to test attribute - # override capability + # override capability. The backend routes the event to the overridden + # session, so verification must query under test_session_id — not the + # tracer's own session. target_event = verify_tracer_span( tracer=integration_tracer, client=integration_client, project=real_project, - session_id=integration_tracer.session_id, + session_id=test_session_id, span_name=test_operation_name, unique_identifier=unique_id, span_attributes={ diff --git a/tests/integration/test_tracing_integration.py b/tests/integration/test_tracing_integration.py index c1ab1244..f2608735 100644 --- a/tests/integration/test_tracing_integration.py +++ b/tests/integration/test_tracing_integration.py @@ -542,8 +542,6 @@ def test_basic_trace_export_verification(self, fetch_events): - Fetch events from API - Assert events exist for session (or log if ingestion delayed) """ - import time - from honeyhive import HoneyHiveTracer, trace tracer = HoneyHiveTracer.init( @@ -563,32 +561,22 @@ def traced_function(x: int) -> int: assert result == 10 # Force flush with explicit wait - flush_result = tracer.flush() - - # Wait for ingestion - time.sleep(5) + tracer.flush() - # Try to fetch events - this verifies the full pipeline - try: - events = fetch_events( - session_id=session_id, - project=os.getenv("HH_PROJECT", "tracing-integration-test"), - max_retries=3, - retry_delay=3.0, - ) + # Fetch events - this verifies the full pipeline. Wait for both the + # session event and the traced function's event. + events = fetch_events( + session_id=session_id, + project=os.getenv("HH_PROJECT", "tracing-integration-test"), + max_retries=10, + retry_delay=3.0, + min_events=2, + ) - if len(events) > 0: - # Full e2e verification passed - assert True, f"Found {len(events)} events for session" - else: - # Events not yet ingested - this is expected in some CI environments - pytest.skip( - f"Events not yet ingested for session {session_id}. " - "This may be due to ingestion delay - verify manually." - ) - except Exception as e: - # API call failed - skip with info - pytest.skip(f"Could not fetch events: {e}") + assert len(events) >= 2, ( + f"Expected session + traced function events for session " + f"{session_id}, got {len(events)}" + ) def test_enrichment_export_verification(self, fetch_events): """Verify enriched spans are exported with correct metadata. @@ -600,8 +588,6 @@ def test_enrichment_export_verification(self, fetch_events): - Fetch events from API - Assert metadata/metrics match what was logged """ - import time - from honeyhive import HoneyHiveTracer, enrich_span, trace tracer = HoneyHiveTracer.init( @@ -624,40 +610,32 @@ def enriched_function(data: str) -> str: assert result == "HELLO WORLD" tracer.flush() - time.sleep(5) - try: - events = fetch_events( - session_id=session_id, - project=os.getenv("HH_PROJECT", "tracing-integration-test"), - ) + # Wait for the session event and the enriched function's event. + events = fetch_events( + session_id=session_id, + project=os.getenv("HH_PROJECT", "tracing-integration-test"), + min_events=2, + ) + + # Check the enrichment metadata was exported. events is List[LegacyEvent]. + all_metadata = {} + for event in events: + if event.metadata: + all_metadata.update(event.metadata) - if len(events) > 0: - # Check if metadata was exported. events is List[LegacyEvent]. - all_metadata = {} - for event in events: - if event.metadata: - all_metadata.update(event.metadata) - - if "test_key" in all_metadata: - assert all_metadata["test_key"] == "test_value" - else: - # Metadata not in expected format, but events exist - pass - else: - pytest.skip(f"Events not yet ingested for session {session_id}") - except Exception as e: - pytest.skip(f"Could not fetch events: {e}") - - def test_session_can_be_retrieved(self): + assert all_metadata.get("test_key") == "test_value", ( + f"Enriched metadata not exported; merged metadata keys: " + f"{sorted(all_metadata.keys())}" + ) + + def test_session_can_be_retrieved(self, fetch_events): """Verify session can be retrieved via API after creation. This is a simpler e2e test that just verifies the session exists in the system, without checking individual events. """ - import time - - from honeyhive import HoneyHive, HoneyHiveTracer + from honeyhive import HoneyHiveTracer tracer = HoneyHiveTracer.init( project=os.getenv("HH_PROJECT", "tracing-integration-test"), @@ -669,19 +647,13 @@ def test_session_can_be_retrieved(self): assert session_id is not None tracer.flush() - time.sleep(3) - - # Try to get the session via the event query surface - try: - client = HoneyHive(api_key=os.getenv("HH_API_KEY")) - session = client.events.get_by_session_id(session_id, limit=10) - # If we got here, the session exists - assert session is not None - assert session.events is not None - except Exception as e: - # Session events might not be accessible yet - pytest.skip(f"Could not retrieve session: {e}") + # The session-start event must become queryable. + events = fetch_events( + session_id=session_id, + project=os.getenv("HH_PROJECT", "tracing-integration-test"), + ) + assert len(events) >= 1, f"Session {session_id} not retrievable" def test_api_client_events_export(self): """Verify events.export() API works correctly. @@ -722,8 +694,6 @@ def test_inputs_outputs_verification(self, fetch_events): - Fetch events from API - Assert inputs and outputs in logged events match function args/return """ - import time - from honeyhive import HoneyHiveTracer, trace tracer = HoneyHiveTracer.init( @@ -751,55 +721,43 @@ def process_data(input_text: str, multiplier: int) -> str: assert result == expected_output tracer.flush() - time.sleep(5) - try: - events = fetch_events( - session_id=session_id, - project=os.getenv("HH_PROJECT"), - ) + # Wait for the session event and the traced function's event. + events = fetch_events( + session_id=session_id, + project=os.getenv("HH_PROJECT"), + min_events=2, + ) + + # `events` is List[LegacyEvent] from fetch_events. + # Find the event for our traced function (event names from bare + # @trace are module-qualified, so match on the suffix). + event_names = [e.event_name or "" for e in events] + func_event = next( + (e for e in events if (e.event_name or "").endswith("process_data")), + None, + ) + assert func_event is not None, ( + f"process_data event not found; got events: {event_names}" + ) - if len(events) > 0: - # `events` is List[LegacyEvent] from fetch_events. - # Find the event for our traced function - func_event = None - for event in events: - if event.event_name == "process_data": - func_event = event - break - - if func_event: - # Verify inputs were captured - inputs = func_event.inputs or {} - assert "input_text" in inputs or "args" in inputs, ( - f"Expected input_text in inputs. Got: {inputs}" - ) - - # Verify outputs were captured - outputs = func_event.outputs or {} - assert outputs is not None, "Outputs should not be None" - - # Check output value matches - output_value = ( - outputs.get("result") - or outputs.get("return_value") - or outputs.get("output") - ) - if output_value: - assert output_value == expected_output, ( - f"Output mismatch: expected '{expected_output}', got '{output_value}'" - ) - else: - # Function event not found by name, check if any event has inputs/outputs - has_inputs = any(e.inputs for e in events) - has_outputs = any(e.outputs for e in events) - assert has_inputs or has_outputs, ( - "No events with inputs/outputs found" - ) - else: - pytest.skip(f"Events not yet ingested for session {session_id}") - except Exception as e: - pytest.skip(f"Could not verify inputs/outputs: {e}") + # Verify inputs were captured + inputs = func_event.inputs or {} + assert "input_text" in inputs or "args" in inputs, ( + f"Expected input_text in inputs. Got: {inputs}" + ) + + # Verify the output value matches + outputs = func_event.outputs or {} + output_value = ( + outputs.get("result") + or outputs.get("return_value") + or outputs.get("output") + ) + assert output_value == expected_output, ( + f"Output mismatch: expected '{expected_output}', got " + f"'{output_value}' (outputs: {outputs})" + ) def test_openai_inputs_outputs_verification(self, fetch_events): """Verify OpenAI call inputs/outputs are logged correctly via instrumentor. @@ -817,8 +775,6 @@ def test_openai_inputs_outputs_verification(self, fetch_events): - choices or llm.output_messages - usage (token counts) """ - import time - try: from openinference.instrumentation.openai import OpenAIInstrumentor except ImportError: @@ -850,72 +806,62 @@ def test_openai_inputs_outputs_verification(self, fetch_events): max_tokens=20, ) - actual_output = response.choices[0].message.content - tracer.flush() - time.sleep(5) + # Wait for the session event and the instrumented LLM event. events = fetch_events( session_id=session_id, project=os.getenv("HH_PROJECT"), + min_events=2, + ) + + # `events` is List[LegacyEvent] from fetch_events. + # Find the LLM event (should have model-related data with actual inputs) + llm_event = None + for event in events: + event_type = event.event_type or "" + event_name = event.event_name or "" + inputs = event.inputs or {} + + # Must have non-empty inputs to be the actual LLM call + if not inputs: + continue + + # Look for OpenAI/LLM events with actual data + if ( + "model" in event_type.lower() + or "chatcompletion" in event_name.lower() + or "chat_history" in inputs + or "messages" in inputs + ): + llm_event = event + break + + assert llm_event is not None, ( + f"No LLM event with inputs found; got events: " + f"{[(e.event_name, e.event_type) for e in events]}" ) - if len(events) > 0: - # `events` is List[LegacyEvent] from fetch_events. - # Find the LLM event (should have model-related data with actual inputs) - llm_event = None - for event in events: - event_type = event.event_type or "" - event_name = event.event_name or "" - inputs = event.inputs or {} - - # Must have non-empty inputs to be the actual LLM call - if not inputs: - continue - - # Look for OpenAI/LLM events with actual data - if ( - "model" in event_type.lower() - or "chatcompletion" in event_name.lower() - or "chat_history" in inputs - or "messages" in inputs - ): - llm_event = event - break - - if llm_event: - inputs = llm_event.inputs or {} - outputs = llm_event.outputs or {} - - # Verify inputs captured the prompt - # OpenInference uses chat_history, messages, or similar - input_str = str(inputs).lower() - assert ( - test_prompt.lower() in input_str - or "integration test" in input_str - or "chat_history" in inputs - or "messages" in inputs - or len(inputs) > 0 - ), f"Expected prompt in inputs. Got: {list(inputs.keys())}" - - # Verify outputs captured the response - output_str = str(outputs).lower() - assert ( - "choices" in outputs - or "content" in output_str - or "message" in output_str - or len(outputs) > 0 - ), f"Expected response in outputs. Got: {list(outputs.keys())}" - - else: - # No specific LLM event found, but check any event has data - has_data = any(e.inputs or e.outputs for e in events) - if has_data: - pass # Some data was captured - else: - pytest.skip("No LLM event with inputs/outputs found") - else: - pytest.skip(f"Events not yet ingested for session {session_id}") + inputs = llm_event.inputs or {} + outputs = llm_event.outputs or {} + + # Verify inputs captured the prompt + # OpenInference uses chat_history, messages, or similar + input_str = str(inputs).lower() + assert ( + test_prompt.lower() in input_str + or "integration test" in input_str + or "chat_history" in inputs + or "messages" in inputs + ), f"Expected prompt in inputs. Got: {list(inputs.keys())}" + + # Verify outputs captured the response + output_str = str(outputs).lower() + assert ( + "choices" in outputs + or "content" in output_str + or "message" in output_str + ), f"Expected response in outputs. Got: {list(outputs.keys())}" finally: instrumentor.uninstrument() @@ -928,8 +874,6 @@ def test_anthropic_inputs_outputs_verification(self, fetch_events): - The completion/response content is captured in outputs - Model name is captured """ - import time - # Skip if Anthropic not available anthropic_key = os.getenv("ANTHROPIC_API_KEY") if not anthropic_key: @@ -966,47 +910,39 @@ def test_anthropic_inputs_outputs_verification(self, fetch_events): messages=[{"role": "user", "content": test_prompt}], ) - actual_output = response.content[0].text - tracer.flush() - time.sleep(5) + # Wait for the session event and the instrumented LLM event. events = fetch_events( session_id=session_id, project=os.getenv("HH_PROJECT"), + min_events=2, + ) + + # `events` is List[LegacyEvent] from fetch_events. + # Find event with Anthropic/LLM data + llm_event = next( + (e for e in events if (e.inputs or {}) or (e.outputs or {})), + None, ) + assert llm_event is not None, ( + f"No event with inputs/outputs found; got events: " + f"{[(e.event_name, e.event_type) for e in events]}" + ) + + inputs = llm_event.inputs or {} + outputs = llm_event.outputs or {} - if len(events) > 0: - # `events` is List[LegacyEvent] from fetch_events. - # Find event with Anthropic/LLM data - llm_event = None - for event in events: - inputs = event.inputs or {} - outputs = event.outputs or {} - if inputs or outputs: - llm_event = event - break - - if llm_event: - inputs = llm_event.inputs or {} - outputs = llm_event.outputs or {} - - # Verify inputs captured the prompt - input_str = str(inputs).lower() - assert ( - "anthropic" in input_str - or "integration test" in input_str - or "messages" in inputs - or len(inputs) > 0 - ), f"Expected prompt in inputs. Got: {list(inputs.keys())}" - - # Verify outputs captured the response - assert len(outputs) > 0, f"Expected outputs. Got empty." - - else: - pytest.skip("No event with inputs/outputs found") - else: - pytest.skip(f"Events not yet ingested for session {session_id}") + # Verify inputs captured the prompt + input_str = str(inputs).lower() + assert ( + "anthropic" in input_str + or "integration test" in input_str + or "messages" in inputs + ), f"Expected prompt in inputs. Got: {list(inputs.keys())}" + + # Verify outputs captured the response + assert len(outputs) > 0, "Expected outputs. Got empty." finally: instrumentor.uninstrument() @@ -1019,8 +955,6 @@ def test_langchain_inputs_outputs_verification(self, fetch_events): - Chain outputs are captured - LLM calls within the chain are traced """ - import time - # Skip if OpenAI not available (LangChain uses OpenAI) openai_key = os.getenv("OPENAI_API_KEY") if not openai_key: @@ -1058,40 +992,25 @@ def test_langchain_inputs_outputs_verification(self, fetch_events): test_input = "langchain verification" response = chain.invoke({"word": test_input}) - actual_output = response.content - tracer.flush() - time.sleep(5) + # Wait for the session event and the instrumented chain events. events = fetch_events( session_id=session_id, project=os.getenv("HH_PROJECT"), + min_events=2, ) - if len(events) > 0: - # `events` is List[LegacyEvent] from fetch_events. - # Find event with chain/LLM data - chain_event = None - for event in events: - inputs = event.inputs or {} - outputs = event.outputs or {} - if inputs or outputs: - chain_event = event - break - - if chain_event: - inputs = chain_event.inputs or {} - outputs = chain_event.outputs or {} - - # Verify inputs were captured - assert len(inputs) > 0 or len(outputs) > 0, ( - "Expected chain inputs/outputs to be captured" - ) - - else: - pytest.skip("No event with inputs/outputs found") - else: - pytest.skip(f"Events not yet ingested for session {session_id}") + # `events` is List[LegacyEvent] from fetch_events. + # Find event with chain/LLM data + chain_event = next( + (e for e in events if (e.inputs or {}) or (e.outputs or {})), + None, + ) + assert chain_event is not None, ( + f"No event with chain inputs/outputs found; got events: " + f"{[(e.event_name, e.event_type) for e in events]}" + ) finally: instrumentor.uninstrument() diff --git a/tests/unit/test_api_config_timeout.py b/tests/unit/test_api_config_timeout.py new file mode 100644 index 00000000..0232f9f1 --- /dev/null +++ b/tests/unit/test_api_config_timeout.py @@ -0,0 +1,24 @@ +"""Unit tests for the configurable APIConfig request timeout. + +Verifies the new ``timeout`` field on APIConfig (defaulting to 5.0s, matching +httpx's own default). Env var / precedence resolution lives in the public client +(see test_client_timeout.py), not in APIConfig itself. +""" + +from honeyhive._generated.api_config import APIConfig + + +class TestAPIConfigTimeoutField: + """Test the APIConfig.timeout field defaults and overrides.""" + + def test_default_timeout_is_five_seconds(self) -> None: + """Default timeout should be 5.0 to match httpx's default.""" + assert APIConfig().timeout == 5.0 + + def test_explicit_timeout_overrides_default(self) -> None: + """An explicit timeout should be stored verbatim.""" + assert APIConfig(timeout=12.0).timeout == 12.0 + + def test_explicit_none_disables_timeout(self) -> None: + """Passing None explicitly disables timeouts (httpx semantics).""" + assert APIConfig(timeout=None).timeout is None diff --git a/tests/unit/test_client_timeout.py b/tests/unit/test_client_timeout.py new file mode 100644 index 00000000..167286bf --- /dev/null +++ b/tests/unit/test_client_timeout.py @@ -0,0 +1,100 @@ +"""Unit tests for HoneyHive client request-timeout configuration. + +Verifies that the ``timeout`` constructor arg and the HH_API_TIMEOUT env var are +threaded into the underlying APIConfig, while preserving the 5.0s default when +nothing is configured. +""" + +from unittest.mock import patch + +from honeyhive.api.client import HoneyHive, _resolve_api_timeout + + +class TestClientTimeoutThreading: + """HoneyHive() threads the timeout into APIConfig.""" + + def test_default_timeout_preserved(self) -> None: + """With no timeout configured, APIConfig keeps its 5.0s default.""" + with patch.dict("os.environ", {}, clear=True): + client = HoneyHive(api_key="k") + assert client.api_config.timeout == 5.0 + + def test_explicit_timeout_is_used(self) -> None: + """An explicit timeout arg is threaded into APIConfig.""" + with patch.dict("os.environ", {}, clear=True): + client = HoneyHive(api_key="k", timeout=20.0) + assert client.api_config.timeout == 20.0 + + def test_explicit_none_keeps_default(self) -> None: + """timeout=None means "unset" at the client layer, keeping the default. + + This is a deliberate asymmetry: HoneyHive(timeout=None) preserves the + 5.0s default, whereas APIConfig(timeout=None) disables timeouts. + """ + with patch.dict("os.environ", {}, clear=True): + client = HoneyHive(api_key="k", timeout=None) + assert client.api_config.timeout == 5.0 + + +class TestClientTimeoutEnvVar: + """HH_API_TIMEOUT resolution at the client layer.""" + + def test_env_var_is_used(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "45"}, clear=True): + client = HoneyHive(api_key="k") + assert client.api_config.timeout == 45.0 + + def test_explicit_arg_beats_env_var(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "45"}, clear=True): + client = HoneyHive(api_key="k", timeout=10.0) + assert client.api_config.timeout == 10.0 + + def test_invalid_env_var_falls_back_to_default(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "nope"}, clear=True): + client = HoneyHive(api_key="k") + assert client.api_config.timeout == 5.0 + + def test_non_positive_arg_falls_back_to_default(self) -> None: + """timeout=0 / negative would break httpx; fall back to the default.""" + with patch.dict("os.environ", {}, clear=True): + assert HoneyHive(api_key="k", timeout=0).api_config.timeout == 5.0 + assert HoneyHive(api_key="k", timeout=-5).api_config.timeout == 5.0 + + def test_non_positive_env_var_falls_back_to_default(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "0"}, clear=True): + assert HoneyHive(api_key="k").api_config.timeout == 5.0 + + +class TestResolveApiTimeout: + """Direct coverage of the _resolve_api_timeout helper.""" + + def test_explicit_wins(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "45"}, clear=True): + assert _resolve_api_timeout(3.0) == 3.0 + + def test_env_used_when_no_explicit(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "45"}, clear=True): + assert _resolve_api_timeout(None) == 45.0 + + def test_returns_none_when_unset(self) -> None: + with patch.dict("os.environ", {}, clear=True): + assert _resolve_api_timeout(None) is None + + def test_invalid_env_warns_and_returns_none(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "bad"}, clear=True): + with patch("honeyhive.api.client.logger") as mock_logger: + assert _resolve_api_timeout(None) is None + mock_logger.warning.assert_called_once() + + def test_non_positive_explicit_warns_and_returns_none(self) -> None: + with patch.dict("os.environ", {}, clear=True): + with patch("honeyhive.api.client.logger") as mock_logger: + assert _resolve_api_timeout(0) is None + assert _resolve_api_timeout(-1.0) is None + assert mock_logger.warning.call_count == 2 + + def test_non_positive_env_warns_and_returns_none(self) -> None: + with patch.dict("os.environ", {"HH_API_TIMEOUT": "-3"}, clear=True): + with patch("honeyhive.api.client.logger") as mock_logger: + assert _resolve_api_timeout(None) is None + mock_logger.warning.assert_called_once() diff --git a/tests/unit/test_config_models_tracer.py b/tests/unit/test_config_models_tracer.py index 5a3e56bc..999acb68 100644 --- a/tests/unit/test_config_models_tracer.py +++ b/tests/unit/test_config_models_tracer.py @@ -14,9 +14,11 @@ # pylint: disable=protected-access # Justification: Unit tests need to verify private method behavior +import json from unittest.mock import Mock, patch import pytest +import requests from pydantic import ValidationError from honeyhive.config.models.tracer import EvaluationConfig, SessionConfig, TracerConfig @@ -58,6 +60,42 @@ def test_initialization_with_defaults(self) -> None: assert config.max_links == 128 assert config.max_span_size == 10 * 1024 * 1024 # 10MB assert config.preserve_core_attributes is True # Default enabled + assert config.requests_session is None + + def test_requests_session_accepts_session_instance(self) -> None: + """Test TracerConfig accepts a custom requests.Session instance.""" + session = requests.Session() + config = TracerConfig(requests_session=session) + + assert config.requests_session is session + + def test_requests_session_python_dump_preserves_instance(self) -> None: + """Test python-mode model_dump passes the Session through by reference. + + The tracer's config merging relies on model_dump() to carry the + session into the unified config, so it must not be masked there. + """ + session = requests.Session() + config = TracerConfig(requests_session=session) + + assert config.model_dump()["requests_session"] is session + + def test_requests_session_json_dump_uses_placeholder(self) -> None: + """Test JSON-mode serialization emits a placeholder instead of raising.""" + config = TracerConfig(requests_session=requests.Session()) + + payload = json.loads(config.model_dump_json()) + + assert payload["requests_session"] == "" + + def test_requests_session_json_dump_none_when_unset(self) -> None: + """Test JSON-mode serialization of the default emits null.""" + with patch.dict("os.environ", {}, clear=True): + config = TracerConfig() + + payload = json.loads(config.model_dump_json()) + + assert payload["requests_session"] is None def test_initialization_with_values(self) -> None: """Test TracerConfig initialization with provided values.""" diff --git a/tests/unit/test_generated_service_timeout.py b/tests/unit/test_generated_service_timeout.py new file mode 100644 index 00000000..4b8be9a1 --- /dev/null +++ b/tests/unit/test_generated_service_timeout.py @@ -0,0 +1,75 @@ +"""Unit tests verifying the generated services pass APIConfig.timeout to httpx. + +The generated service functions create an httpx client per request. These tests +confirm the configured ``timeout`` (5.0s by default) is forwarded to both the +sync ``httpx.Client`` and async ``httpx.AsyncClient`` constructors. getDatasets +is used as the representative operation (the customer's reported use case). +""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from honeyhive._generated.api_config import APIConfig +from honeyhive._generated.services import Datasets_service as datasets_svc +from honeyhive._generated.services import async_Datasets_service as datasets_svc_async + + +def _mock_response() -> Mock: + """A 200 response whose body satisfies GetDatasetsResponse.""" + response = Mock() + response.status_code = 200 + response.json.return_value = {"datasets": []} + return response + + +class TestSyncServiceTimeout: + """Sync generated services forward api_config.timeout to httpx.Client.""" + + @patch("honeyhive._generated.services.Datasets_service.httpx.Client") + def test_explicit_timeout_is_passed(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.request.return_value = _mock_response() + mock_client_cls.return_value.__enter__.return_value = mock_client + + datasets_svc.getDatasets(api_config_override=APIConfig(timeout=2.5)) + + assert mock_client_cls.call_args.kwargs["timeout"] == 2.5 + + @patch("honeyhive._generated.services.Datasets_service.httpx.Client") + def test_default_timeout_is_passed(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.request.return_value = _mock_response() + mock_client_cls.return_value.__enter__.return_value = mock_client + + datasets_svc.getDatasets(api_config_override=APIConfig()) + + assert mock_client_cls.call_args.kwargs["timeout"] == 5.0 + + +class TestAsyncServiceTimeout: + """Async generated services forward api_config.timeout to httpx.AsyncClient.""" + + @pytest.mark.asyncio + @patch("honeyhive._generated.services.async_Datasets_service.httpx.AsyncClient") + async def test_explicit_timeout_is_passed(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response()) + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client_cls.return_value.__aexit__.return_value = None + + await datasets_svc_async.getDatasets(api_config_override=APIConfig(timeout=2.5)) + + assert mock_client_cls.call_args.kwargs["timeout"] == 2.5 + + @pytest.mark.asyncio + @patch("honeyhive._generated.services.async_Datasets_service.httpx.AsyncClient") + async def test_default_timeout_is_passed(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.request = AsyncMock(return_value=_mock_response()) + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client_cls.return_value.__aexit__.return_value = None + + await datasets_svc_async.getDatasets(api_config_override=APIConfig()) + + assert mock_client_cls.call_args.kwargs["timeout"] == 5.0 diff --git a/tests/unit/test_tracer_instrumentation_initialization.py b/tests/unit/test_tracer_instrumentation_initialization.py index 36e3138d..d5b6b725 100644 --- a/tests/unit/test_tracer_instrumentation_initialization.py +++ b/tests/unit/test_tracer_instrumentation_initialization.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock, Mock, call, mock_open, patch import pytest +import requests from honeyhive.tracer.core import HoneyHiveTracer @@ -48,6 +49,9 @@ def __init__(self) -> None: self.config.skip_backend_session_creation = False self.config.session = Mock() self.config.session.inputs = {} + # Mirror the real TracerConfig default (Mock would auto-create a + # truthy attribute otherwise, which no real config ever produces) + self.config.requests_session = None # Span limit configuration self.config.max_attributes = 1024 self.config.max_events = 1024 @@ -928,6 +932,57 @@ def test__create_otlp_exporter_success( assert "X-Source" in headers assert "X-Project" not in headers + @patch("honeyhive.tracer.instrumentation.initialization.HoneyHiveOTLPExporter") + @patch( + "honeyhive.tracer.instrumentation.initialization._get_optimal_session_config" + ) + @patch("honeyhive.tracer.instrumentation.initialization.safe_log") + @patch.dict("os.environ", {"HH_OTLP_ENABLED": "true"}) + def test__create_otlp_exporter_default_omits_session_kwarg( + self, mock_log: Any, mock_session_config: Any, mock_exporter: Any + ) -> None: + """Without a custom requests_session, no session kwarg is passed. + + Passing session=None would make HoneyHiveOTLPExporter skip its + optimized connection-pooled session (it gates on "session" in kwargs). + """ + # Arrange + mock_session_config.return_value = Mock() + self.mock_tracer.config.otlp_enabled = True + self.mock_tracer.config.requests_session = None + self.mock_tracer.test_mode = False + + # Act + initialization._create_otlp_exporter(self.mock_tracer) + + # Assert + mock_exporter.assert_called_once() + assert "session" not in mock_exporter.call_args[1] + + @patch("honeyhive.tracer.instrumentation.initialization.HoneyHiveOTLPExporter") + @patch( + "honeyhive.tracer.instrumentation.initialization._get_optimal_session_config" + ) + @patch("honeyhive.tracer.instrumentation.initialization.safe_log") + @patch.dict("os.environ", {"HH_OTLP_ENABLED": "true"}) + def test__create_otlp_exporter_passes_custom_requests_session( + self, mock_log: Any, mock_session_config: Any, mock_exporter: Any + ) -> None: + """A custom requests_session from config reaches the exporter.""" + # Arrange + mock_session_config.return_value = Mock() + custom_session = Mock(spec=requests.Session) + self.mock_tracer.config.otlp_enabled = True + self.mock_tracer.config.requests_session = custom_session + self.mock_tracer.test_mode = False + + # Act + initialization._create_otlp_exporter(self.mock_tracer) + + # Assert + mock_exporter.assert_called_once() + assert mock_exporter.call_args[1]["session"] is custom_session + @patch("honeyhive.tracer.instrumentation.initialization.safe_log") def test__create_otlp_exporter_disabled(self, mock_log: Any) -> None: """Test OTLP exporter creation when disabled.""" diff --git a/tests/unit/test_tracer_processing_otlp_exporter.py b/tests/unit/test_tracer_processing_otlp_exporter.py index 63baba5c..bcef0c5d 100644 --- a/tests/unit/test_tracer_processing_otlp_exporter.py +++ b/tests/unit/test_tracer_processing_otlp_exporter.py @@ -770,6 +770,67 @@ def test_shutdown_with_session_stats_exception( mock_tracer, "debug", "HoneyHiveOTLPExporter shutdown completed" ) + @patch("honeyhive.tracer.processing.otlp_exporter.safe_log") + def test_shutdown_does_not_close_user_provided_session( + self, mock_safe_log: Mock, mock_requests_session: Mock + ) -> None: + """Test shutdown leaves a user-provided session open. + + Users may share the session with the rest of their application, so + the SDK must only close sessions it created itself. Uses the real + OTLPJSONExporter to exercise the full shutdown path. + + Args: + mock_safe_log: Mock for safe_log function + mock_requests_session: Mock requests session + """ + # Arrange + exporter = HoneyHiveOTLPExporter( + session=mock_requests_session, + endpoint=TEST_OTLP_ENDPOINT, + ) + + # Act + exporter.shutdown() + + # Assert + assert exporter._is_shutdown is True + mock_requests_session.close.assert_not_called() + + @patch("honeyhive.tracer.processing.otlp_exporter.OTLPJSONExporter") + @patch("honeyhive.tracer.processing.otlp_exporter.create_optimized_otlp_session") + @patch("honeyhive.tracer.processing.otlp_exporter.safe_log") + def test_shutdown_closes_owned_optimized_session( + self, + mock_safe_log: Mock, + mock_create_session: Mock, + mock_json_exporter: Mock, + mock_requests_session: Mock, + ) -> None: + """Test shutdown closes the optimized session the exporter created. + + Args: + mock_safe_log: Mock for safe_log function + mock_create_session: Mock for create_optimized_otlp_session + mock_json_exporter: Mock for OTLPJSONExporter class + mock_requests_session: Mock requests session + """ + # Arrange + mock_create_session.return_value = mock_requests_session + mock_json_exporter.return_value = Mock() + + exporter = HoneyHiveOTLPExporter( + use_optimized_session=True, + endpoint=TEST_OTLP_ENDPOINT, + ) + assert exporter._session is mock_requests_session + + # Act + exporter.shutdown() + + # Assert + mock_requests_session.close.assert_called_once() + class TestHoneyHiveOTLPExporterEdgeCases: """Test edge cases and comprehensive coverage scenarios.""" @@ -918,6 +979,43 @@ def test_json_exporter_initialization(self, mock_session_class: Mock) -> None: assert exporter.timeout == 30.0 assert exporter._is_shutdown is False + @patch("honeyhive.tracer.processing.otlp_exporter.safe_log") + def test_json_exporter_shutdown_closes_owned_session( + self, mock_safe_log: Mock + ) -> None: + """Test shutdown closes the session the exporter created itself. + + Args: + mock_safe_log: Mock for safe_log function + """ + # Arrange + exporter = OTLPJSONExporter(TEST_OTLP_ENDPOINT) + + # Act / Assert + with patch.object(exporter.session, "close") as mock_close: + exporter.shutdown() + mock_close.assert_called_once() + + @patch("honeyhive.tracer.processing.otlp_exporter.safe_log") + def test_json_exporter_shutdown_does_not_close_provided_session( + self, mock_safe_log: Mock, mock_requests_session: Mock + ) -> None: + """Test shutdown leaves an externally provided session open. + + Args: + mock_safe_log: Mock for safe_log function + mock_requests_session: Mock requests session + """ + # Arrange + exporter = OTLPJSONExporter(TEST_OTLP_ENDPOINT, session=mock_requests_session) + + # Act + exporter.shutdown() + + # Assert + assert exporter._is_shutdown is True + mock_requests_session.close.assert_not_called() + @patch("honeyhive.tracer.processing.otlp_exporter.requests.Session") def test_json_exporter_export_success( self, diff --git a/tox.ini b/tox.ini index ab3bd3f4..bf682417 100644 --- a/tox.ini +++ b/tox.ini @@ -160,8 +160,6 @@ passenv = HH_PROJECT HH_API_URL HH_SOURCE - HH_INTEGRATION_SKIP_KNOWN_FAILURES - HH_INTEGRATION_KNOWN_FAILURES_FILE [testenv:unit] description = run unit tests only (fast, mocked)