From 15de9e41b1ce628b3117df9860b14fec464a4069 Mon Sep 17 00:00:00 2001 From: dylanholmes <4370153+dylanholmes@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:26:44 -0700 Subject: [PATCH 1/5] Add observability --- CHANGELOG.md | 6 + .../drivers/observability-drivers.md | 185 +++++++++++++ .../structures/observability.md | 57 ++++ griptape/common/__init__.py | 3 + griptape/common/observable.py | 77 ++++++ griptape/drivers/__init__.py | 9 + .../griptape_cloud_event_listener_driver.py | 12 + griptape/drivers/observability/__init__.py | 0 .../base_observability_driver.py | 31 +++ .../griptape_cloud_observability_driver.py | 104 ++++++++ .../no_op_observability_driver.py | 19 ++ .../open_telemetry_observability_driver.py | 86 ++++++ .../prompt/amazon_bedrock_prompt_driver.py | 3 + ...mazon_sagemaker_jumpstart_prompt_driver.py | 4 +- .../drivers/prompt/anthropic_prompt_driver.py | 3 + griptape/drivers/prompt/base_prompt_driver.py | 2 + .../drivers/prompt/cohere_prompt_driver.py | 3 + .../drivers/prompt/dummy_prompt_driver.py | 3 + .../drivers/prompt/google_prompt_driver.py | 3 + .../prompt/huggingface_hub_prompt_driver.py | 4 +- .../huggingface_pipeline_prompt_driver.py | 4 +- .../drivers/prompt/ollama_prompt_driver.py | 3 + .../prompt/openai_chat_prompt_driver.py | 3 + griptape/observability/__init__.py | 3 + griptape/observability/observability.py | 57 ++++ griptape/structures/agent.py | 2 + griptape/structures/pipeline.py | 2 + griptape/structures/structure.py | 4 + griptape/structures/workflow.py | 2 + griptape/tools/base_tool.py | 2 + poetry.lock | 228 +++++++++++++++- pyproject.toml | 25 ++ tests/unit/common/test_observable.py | 238 +++++++++++++++++ ...st_griptape_cloud_event_listener_driver.py | 27 +- tests/unit/drivers/observability/__init__.py | 0 ...est_griptape_cloud_observability_driver.py | 250 ++++++++++++++++++ ...est_open_telemetry_observability_driver.py | 190 +++++++++++++ tests/unit/observability/__init__.py | 0 .../unit/observability/test_observability.py | 42 +++ tests/utils/expected_spans.py | 86 ++++++ 40 files changed, 1776 insertions(+), 6 deletions(-) create mode 100644 docs/griptape-framework/drivers/observability-drivers.md create mode 100644 docs/griptape-framework/structures/observability.md create mode 100644 griptape/common/observable.py create mode 100644 griptape/drivers/observability/__init__.py create mode 100644 griptape/drivers/observability/base_observability_driver.py create mode 100644 griptape/drivers/observability/griptape_cloud_observability_driver.py create mode 100644 griptape/drivers/observability/no_op_observability_driver.py create mode 100644 griptape/drivers/observability/open_telemetry_observability_driver.py create mode 100644 griptape/observability/__init__.py create mode 100644 griptape/observability/observability.py create mode 100644 tests/unit/common/test_observable.py create mode 100644 tests/unit/drivers/observability/__init__.py create mode 100644 tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py create mode 100644 tests/unit/drivers/observability/test_open_telemetry_observability_driver.py create mode 100644 tests/unit/observability/__init__.py create mode 100644 tests/unit/observability/test_observability.py create mode 100644 tests/utils/expected_spans.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d7fd92f..f636f548a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `OllamaEmbeddingDriver` for generating embeddings with Ollama. - `GriptapeCloudKnowledgeBaseVectorStoreDriver` to query Griptape Cloud Knowledge Bases. - `GriptapeCloudEventListenerDriver.api_key` defaults to the value in the `GT_CLOUD_API_KEY` environment variable. +- `BaseObservabilityDriver` as the base class for all Observability Drivers. +- `DummyObservabilityDriver` as a no-op Observability Driver. +- `OpenTelemetryObservabilityDriver` for sending observability data to an open telemetry collector or vendor. +- `GriptapeCloudObservabilityDriver` for sending observability data to Griptape Cloud. +- `Observability` context manager for enabling observability and configuring which Observability Driver to use. +- `@observable` decorator for selecting which functions/methods to provide observability for. ### Changed - **BREAKING**: `BaseVectorStoreDriver.upsert_text_artifacts` optional arguments are now keyword-only arguments. diff --git a/docs/griptape-framework/drivers/observability-drivers.md b/docs/griptape-framework/drivers/observability-drivers.md new file mode 100644 index 000000000..41cbf26af --- /dev/null +++ b/docs/griptape-framework/drivers/observability-drivers.md @@ -0,0 +1,185 @@ +# Observability Drivers + +Observability Drivers are used by [Observability](../structures/observability.md) to send telemetry (metrics and traces) related to the execution of an LLM application. The telemetry can be used to monitor the application and to diagnose and troubleshoot issues. All Observability Drivers implement the following methods: + +* `__enter__()` sets up the Driver. +* `__exit__()` tears down the Driver. +* `observe()` wraps all functions and methods marked with the `@observable` decorator. At a bare minimum, implementations call the wrapped function and return its result (a no-op). This enables the Driver to generate telemetry related to the invocation's call arguments, return values, exceptions, latency, etc. + +## Griptape Cloud + +The Griptape Cloud Observability Driver instruments `@observable` functions and methods with metrics and traces for use with the Griptape Cloud. + +!!! note + For the Griptape Cloud Observability Driver to function as intended, it must be run from within either a Managed Structure on Griptape Cloud, + or locally via the [Skatepark Emulator](https://github.com/griptape-ai/griptape-cli?tab=readme-ov-file#skatepark-emulator). + +Here is an example of how to use the `GriptapeCloudObservabilityDriver` with the `Observability` context manager to send the telemetry to Griptape Cloud: + + +```python title="PYTEST_IGNORE" +from griptape.drivers import GriptapeCloudObservabilityDriver +from griptape.rules import Rule +from griptape.structures import Agent +from griptape.observability import Observability + +observability_driver = GriptapeCloudObservabilityDriver() + +with Observability(observability_driver=observability_driver): + agent = Agent(rules=[Rule("Output one word")]) + agent.run("Name an animal") +``` + + +## OpenTelemetry + +The [OpenTelemetry](https://opentelemetry.io/) Observability Driver instruments `@observable` functions and methods with metrics and traces for use with OpenTelemetry. You must configure a destination for the telemetry by providing a `SpanProcessor` to the Driver. + +Here is an example of how to use the `OpenTelemetryObservabilityDriver` with the `Observability` context manager to output the telemetry directly to the console: + +```python title="PYTEST_IGNORE" +from griptape.drivers import OpenTelemetryObservabilityDriver +from griptape.rules import Rule +from griptape.structures import Agent +from griptape.observability import Observability +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor + +observability_driver = OpenTelemetryObservabilityDriver( + service_name="my-gt-app", + span_processor=BatchSpanProcessor(ConsoleSpanExporter()) +) + +with Observability(observability_driver=observability_driver): + agent = Agent(rules=[Rule("Output one word")]) + agent.run("Name an animal") +``` + +Output (only relevant because of use of `ConsoleSpanExporter`): +``` +[06/18/24 06:57:22] INFO PromptTask 2d8ef95bf817480188ae2f74e754308a + Input: Name an animal +[06/18/24 06:57:23] INFO PromptTask 2d8ef95bf817480188ae2f74e754308a + Output: Elephant +{ + "name": "Agent.before_run()", + "context": { + "trace_id": "0x4f3d72f7ff4e6a453f5c950fa097583e", + "span_id": "0x8cf827b375f6922f", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": "0x580276d16c584de3", + "start_time": "2024-06-18T13:57:22.640040Z", + "end_time": "2024-06-18T13:57:22.640822Z", + "status": { + "status_code": "OK" + }, + "attributes": {}, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "my-gt-app" + }, + "schema_url": "" + } +} +{ + "name": "Agent.try_run()", + "context": { + "trace_id": "0x4f3d72f7ff4e6a453f5c950fa097583e", + "span_id": "0x7191a27da608cbe7", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": "0x580276d16c584de3", + "start_time": "2024-06-18T13:57:22.640846Z", + "end_time": "2024-06-18T13:57:23.287311Z", + "status": { + "status_code": "OK" + }, + "attributes": {}, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "my-gt-app" + }, + "schema_url": "" + } +} +{ + "name": "Agent.after_run()", + "context": { + "trace_id": "0x4f3d72f7ff4e6a453f5c950fa097583e", + "span_id": "0x99824dd1bc842f66", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": "0x580276d16c584de3", + "start_time": "2024-06-18T13:57:23.287707Z", + "end_time": "2024-06-18T13:57:23.288666Z", + "status": { + "status_code": "OK" + }, + "attributes": {}, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "my-gt-app" + }, + "schema_url": "" + } +} +{ + "name": "Agent.run()", + "context": { + "trace_id": "0x4f3d72f7ff4e6a453f5c950fa097583e", + "span_id": "0x580276d16c584de3", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": "0xa42d36d9fff76325", + "start_time": "2024-06-18T13:57:22.640021Z", + "end_time": "2024-06-18T13:57:23.288694Z", + "status": { + "status_code": "OK" + }, + "attributes": {}, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "my-gt-app" + }, + "schema_url": "" + } +} +{ + "name": "main", + "context": { + "trace_id": "0x4f3d72f7ff4e6a453f5c950fa097583e", + "span_id": "0xa42d36d9fff76325", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": null, + "start_time": "2024-06-18T13:57:22.607005Z", + "end_time": "2024-06-18T13:57:23.288764Z", + "status": { + "status_code": "OK" + }, + "attributes": {}, + "events": [], + "links": [], + "resource": { + "attributes": { + "service.name": "my-gt-app" + }, + "schema_url": "" + } +} +``` + + diff --git a/docs/griptape-framework/structures/observability.md b/docs/griptape-framework/structures/observability.md new file mode 100644 index 000000000..5a9e9c51c --- /dev/null +++ b/docs/griptape-framework/structures/observability.md @@ -0,0 +1,57 @@ +## Overview + +The [Observability](../../reference/griptape/observability/observability.md) context manager sends telemetry (metrics and traces) for all functions and methods annotated with the `@observable` decorator to a destination of your choice. This is useful for monitoring and debugging your application. + +Observability is completely optional. To opt in, wrap your application code with the [Observability](../../reference/griptape/observability/observability.md) context manager, for example: + +```python title="PYTEST_IGNORE" +from griptape.drivers import GriptapeCloudObservabilityDriver +from griptape.structures import Agent +from griptape.observability import Observability + +observability_driver = GriptapeCloudObservabilityDriver() + +with Observability(observability_driver=observability_driver): + # Important! Only code within this block is subject to observability + agent = Agent() + agent.run("Name the five greatest rappers of all time") +``` + +!!! info + For available Drivers (and destinations), see [Observability Drivers](../drivers/observability-drivers.md). + +## Tracing Custom Code + +All functions and methods annotated with the `@observable` decorator will be traced when invoked within the context of the [Observability](../../reference/griptape/observability/observability.md) context manager, including functions and methods defined outside of the Griptape framework. Thus to trace custom code, you just need to add the `@observable` decorator to your function or method, then invoke it within the [Observability](../../reference/griptape/observability/observability.md) context manager. + +For example: + +```python title="PYTEST_IGNORE" +import time +from griptape.drivers import GriptapeCloudObservabilityDriver +from griptape.rules import Rule +from griptape.structures import Agent +from griptape.observability import Observability +from griptape.common import observable + +# Decorate a function +@observable +def my_function(): + time.sleep(3) + +class MyClass: + # Decorate a method + @observable + def my_method(self): + time.sleep(1) + my_function() + time.sleep(2) + +observability_driver = GriptapeCloudObservabilityDriver() + +# When invoking the instrumented code from within the Observability context manager, the +# telemetry for the custom code will be sent to the destination specified by the driver. +with Observability(observability_driver=observability_driver): + my_function() + MyClass().my_method() +``` \ No newline at end of file diff --git a/griptape/common/__init__.py b/griptape/common/__init__.py index 8324bcb9d..6ceff61eb 100644 --- a/griptape/common/__init__.py +++ b/griptape/common/__init__.py @@ -18,6 +18,7 @@ from .reference import Reference +from .observable import observable, Observable __all__ = [ "BaseMessage", @@ -35,4 +36,6 @@ "Reference", "BaseAction", "ToolAction", + "observable", + "Observable", ] diff --git a/griptape/common/observable.py b/griptape/common/observable.py new file mode 100644 index 000000000..aa675dfbe --- /dev/null +++ b/griptape/common/observable.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import functools +from inspect import isfunction +from typing import Any, Callable, Optional, TypeVar, cast + +from attrs import Factory, define, field + +T = TypeVar("T", bound=Callable) + + +def observable(*args: T | Any, **kwargs: Any) -> T: + return cast(T, Observable(*args, **kwargs)) + + +class Observable: + @define + class Call: + func: Callable = field(kw_only=True) + instance: Optional[Any] = field(default=None, kw_only=True) + args: tuple[Any, ...] = field(default=Factory(tuple), kw_only=True) + kwargs: dict[str, Any] = field(default=Factory(dict), kw_only=True) + decorator_args: tuple[Any, ...] = field(default=Factory(tuple), kw_only=True) + decorator_kwargs: dict[str, Any] = field(default=Factory(dict), kw_only=True) + + def __call__(self) -> Any: + # If self.func has a __self__ attribute, it is a bound method and we do not need to pass the instance. + args = (self.instance, *self.args) if self.instance and not hasattr(self.func, "__self__") else self.args + return self.func(*args, **self.kwargs) + + @property + def tags(self) -> Optional[list[str]]: + return self.decorator_kwargs.get("tags") + + def __init__(self, *args, **kwargs) -> None: + self._instance = None + if len(args) == 1 and len(kwargs) == 0 and isfunction(args[0]): + # Parameterless call. In otherwords, the `@observable` annotation + # was not followed by parentheses. + self._func = args[0] + functools.update_wrapper(self, self._func) + self.decorator_args = () + self.decorator_kwargs = {} + else: + # Parameterized call. In otherwords, the `@observable` annotation + # was followed by parentheses, for example `@observable()`, + # `@observable("x")` or `@observable(y="y")`. + self._func = None + self.decorator_args = args + self.decorator_kwargs = kwargs + + def __get__(self, obj: Any, objtype: Any = None) -> Observable: + self._instance = obj + return self + + def __call__(self, *args, **kwargs) -> Any: + if self._func: + # Parameterless call (self._func was a set in __init__) + from griptape.observability.observability import Observability + + return Observability.observe( + Observable.Call( + func=self._func, + instance=self._instance, + args=args, + kwargs=kwargs, + decorator_args=self.decorator_args, + decorator_kwargs=self.decorator_kwargs, + ) + ) + else: + # Parameterized call, create and return the "real" observable decorator + func = args[0] + decorated_func = Observable(func) + decorated_func.decorator_args = self.decorator_args + decorated_func.decorator_kwargs = self.decorator_kwargs + return decorated_func diff --git a/griptape/drivers/__init__.py b/griptape/drivers/__init__.py index 4ecb6c9bd..8d84b68cf 100644 --- a/griptape/drivers/__init__.py +++ b/griptape/drivers/__init__.py @@ -109,6 +109,11 @@ from .audio_transcription.dummy_audio_transcription_driver import DummyAudioTranscriptionDriver from .audio_transcription.openai_audio_transcription_driver import OpenAiAudioTranscriptionDriver +from .observability.base_observability_driver import BaseObservabilityDriver +from .observability.no_op_observability_driver import NoOpObservabilityDriver +from .observability.open_telemetry_observability_driver import OpenTelemetryObservabilityDriver +from .observability.griptape_cloud_observability_driver import GriptapeCloudObservabilityDriver + __all__ = [ "BasePromptDriver", "OpenAiChatPromptDriver", @@ -202,4 +207,8 @@ "BaseAudioTranscriptionDriver", "DummyAudioTranscriptionDriver", "OpenAiAudioTranscriptionDriver", + "BaseObservabilityDriver", + "NoOpObservabilityDriver", + "OpenTelemetryObservabilityDriver", + "GriptapeCloudObservabilityDriver", ] diff --git a/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py b/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py index cf9dd7bd2..733f0baa2 100644 --- a/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py +++ b/griptape/drivers/event_listener/griptape_cloud_event_listener_driver.py @@ -7,6 +7,7 @@ from attrs import Attribute, Factory, define, field from griptape.drivers.event_listener.base_event_listener_driver import BaseEventListenerDriver +from griptape.events.base_event import BaseEvent @define @@ -38,6 +39,17 @@ def validate_run_id(self, _: Attribute, structure_run_id: str) -> None: "structure_run_id must be set either in the constructor or as an environment variable (GT_CLOUD_STRUCTURE_RUN_ID).", ) + def publish_event(self, event: BaseEvent | dict, *, flush: bool = False) -> None: + from griptape.observability.observability import Observability + + event_payload = event.to_dict() if isinstance(event, BaseEvent) else event + + span_id = Observability.get_span_id() + if span_id is not None: + event_payload["span_id"] = span_id + + super().publish_event(event_payload, flush=flush) + def try_publish_event_payload(self, event_payload: dict) -> None: url = urljoin(self.base_url.strip("/"), f"/api/structure-runs/{self.structure_run_id}/events") diff --git a/griptape/drivers/observability/__init__.py b/griptape/drivers/observability/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/griptape/drivers/observability/base_observability_driver.py b/griptape/drivers/observability/base_observability_driver.py new file mode 100644 index 000000000..41b779578 --- /dev/null +++ b/griptape/drivers/observability/base_observability_driver.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Optional + +from attrs import define + +if TYPE_CHECKING: + from types import TracebackType + + from griptape.common import Observable + + +@define +class BaseObservabilityDriver(ABC): + def __enter__(self) -> None: # noqa: B027 + pass + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + exc_traceback: Optional[TracebackType], + ) -> bool: + return False + + @abstractmethod + def observe(self, call: Observable.Call) -> Any: ... + + @abstractmethod + def get_span_id(self) -> Optional[str]: ... diff --git a/griptape/drivers/observability/griptape_cloud_observability_driver.py b/griptape/drivers/observability/griptape_cloud_observability_driver.py new file mode 100644 index 000000000..c6df9cd35 --- /dev/null +++ b/griptape/drivers/observability/griptape_cloud_observability_driver.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Optional +from urllib.parse import urljoin +from uuid import UUID + +import requests +from attrs import Attribute, Factory, define, field +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult +from opentelemetry.sdk.util import ns_to_iso_str +from opentelemetry.trace import INVALID_SPAN, get_current_span + +from griptape.drivers.observability.open_telemetry_observability_driver import OpenTelemetryObservabilityDriver + +if TYPE_CHECKING: + from collections.abc import Sequence + + from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor + + +@define +class GriptapeCloudObservabilityDriver(OpenTelemetryObservabilityDriver): + service_name: str = field(default="griptape-cloud", kw_only=True) + base_url: str = field( + default=Factory(lambda: os.getenv("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")), kw_only=True + ) + api_key: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_API_KEY")), kw_only=True) + headers: dict = field( + default=Factory(lambda self: {"Authorization": f"Bearer {self.api_key}"}, takes_self=True), kw_only=True + ) + structure_run_id: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_STRUCTURE_RUN_ID")), kw_only=True) + span_processor: SpanProcessor = field( + default=Factory( + lambda self: BatchSpanProcessor( + GriptapeCloudObservabilityDriver.SpanExporter( + base_url=self.base_url, + api_key=self.api_key, + headers=self.headers, + structure_run_id=self.structure_run_id, + ) + ), + takes_self=True, + ), + kw_only=True, + ) + trace_provider: TracerProvider = field(default=Factory(lambda: TracerProvider()), kw_only=True) + + @staticmethod + def format_trace_id(trace_id: int) -> str: + return str(UUID(int=trace_id)) + + @staticmethod + def format_span_id(span_id: int) -> str: + return str(UUID(int=span_id)) + + @define + class SpanExporter(SpanExporter): + base_url: str = field(kw_only=True) + api_key: str = field(kw_only=True) + headers: dict = field(kw_only=True) + structure_run_id: str = field(kw_only=True) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + url = urljoin(self.base_url.strip("/"), f"/api/structure-runs/{self.structure_run_id}/spans") + payload = [ + { + "trace_id": GriptapeCloudObservabilityDriver.format_trace_id(span.context.trace_id), + "span_id": GriptapeCloudObservabilityDriver.format_span_id(span.context.span_id), + "parent_id": GriptapeCloudObservabilityDriver.format_span_id(span.parent.span_id) + if span.parent + else None, + "name": span.name, + "start_time": ns_to_iso_str(span.start_time) if span.start_time else None, + "end_time": ns_to_iso_str(span.end_time) if span.end_time else None, + "status": span.status.status_code.name, + "attributes": {**span.attributes} if span.attributes else {}, + "events": [ + { + "name": event.name, + "timestamp": ns_to_iso_str(event.timestamp) if event.timestamp else None, + "attributes": {**event.attributes} if event.attributes else {}, + } + for event in span.events + ], + } + for span in spans + ] + response = requests.post(url=url, json=payload, headers=self.headers) + return SpanExportResult.SUCCESS if response.status_code == 200 else SpanExportResult.FAILURE + + @structure_run_id.validator # pyright: ignore[reportAttributeAccessIssue] + def validate_run_id(self, _: Attribute, structure_run_id: str) -> None: + if structure_run_id is None: + raise ValueError( + "structure_run_id must be set either in the constructor or as an environment variable (GT_CLOUD_STRUCTURE_RUN_ID)." + ) + + def get_span_id(self) -> Optional[str]: + span = get_current_span() + if span is INVALID_SPAN: + return None + return GriptapeCloudObservabilityDriver.format_span_id(span.get_span_context().span_id) diff --git a/griptape/drivers/observability/no_op_observability_driver.py b/griptape/drivers/observability/no_op_observability_driver.py new file mode 100644 index 000000000..c0fc9bfcf --- /dev/null +++ b/griptape/drivers/observability/no_op_observability_driver.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from attrs import define + +from griptape.drivers import BaseObservabilityDriver + +if TYPE_CHECKING: + from griptape.common import Observable + + +@define +class NoOpObservabilityDriver(BaseObservabilityDriver): + def observe(self, call: Observable.Call) -> Any: + return call() + + def get_span_id(self) -> Optional[str]: + return None diff --git a/griptape/drivers/observability/open_telemetry_observability_driver.py b/griptape/drivers/observability/open_telemetry_observability_driver.py new file mode 100644 index 000000000..fec067a4b --- /dev/null +++ b/griptape/drivers/observability/open_telemetry_observability_driver.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from attrs import Factory, define, field +from opentelemetry.instrumentation.threading import ThreadingInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import SpanProcessor, TracerProvider +from opentelemetry.trace import INVALID_SPAN, Status, StatusCode, Tracer, format_span_id, get_current_span, get_tracer + +from griptape.drivers import BaseObservabilityDriver + +if TYPE_CHECKING: + from types import TracebackType + + from griptape.common import Observable + + +@define +class OpenTelemetryObservabilityDriver(BaseObservabilityDriver): + service_name: str = field(kw_only=True) + span_processor: SpanProcessor = field(kw_only=True) + trace_provider: TracerProvider = field( + default=Factory( + lambda self: TracerProvider(resource=Resource(attributes={"service.name": self.service_name})), + takes_self=True, + ), + kw_only=True, + ) + _tracer: Optional[Tracer] = None + _root_span_context_manager: Any = None + + def __attrs_post_init__(self) -> None: + self.trace_provider.add_span_processor(self.span_processor) + self._tracer = get_tracer(self.service_name, tracer_provider=self.trace_provider) + + def __enter__(self) -> None: + ThreadingInstrumentor().instrument() + self._root_span_context_manager = self._tracer.start_as_current_span("main") # pyright: ignore[reportCallIssue] + self._root_span_context_manager.__enter__() + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + exc_traceback: Optional[TracebackType], + ) -> bool: + root_span = get_current_span() + if exc_value: + root_span = get_current_span() + root_span.set_status(Status(StatusCode.ERROR)) + root_span.record_exception(exc_value) + else: + root_span.set_status(Status(StatusCode.OK)) + if self._root_span_context_manager: + self._root_span_context_manager.__exit__(exc_type, exc_value, exc_traceback) + self._root_span_context_manager = None + self.trace_provider.force_flush() + ThreadingInstrumentor().uninstrument() + return False + + def observe(self, call: Observable.Call) -> Any: + func = call.func + instance = call.instance + tags = call.tags + + class_name = f"{instance.__class__.__name__}." if instance else "" + span_name = f"{class_name}{func.__name__}()" + with self._tracer.start_as_current_span(span_name) as span: # pyright: ignore[reportCallIssue] + if tags is not None: + span.set_attribute("tags", tags) + + try: + result = call() + span.set_status(Status(StatusCode.OK)) + return result + except Exception as e: + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(e) + raise e + + def get_span_id(self) -> Optional[str]: + span = get_current_span() + if span is INVALID_SPAN: + return None + return format_span_id(span.get_span_context().span_id) diff --git a/griptape/drivers/prompt/amazon_bedrock_prompt_driver.py b/griptape/drivers/prompt/amazon_bedrock_prompt_driver.py index 4cb5801d3..b663d06fd 100644 --- a/griptape/drivers/prompt/amazon_bedrock_prompt_driver.py +++ b/griptape/drivers/prompt/amazon_bedrock_prompt_driver.py @@ -26,6 +26,7 @@ TextDeltaMessageContent, TextMessageContent, ToolAction, + observable, ) from griptape.drivers import BasePromptDriver from griptape.tokenizers import AmazonBedrockTokenizer, BaseTokenizer @@ -55,6 +56,7 @@ class AmazonBedrockPromptDriver(BasePromptDriver): use_native_tools: bool = field(default=True, kw_only=True, metadata={"serializable": True}) tool_choice: dict = field(default=Factory(lambda: {"auto": {}}), kw_only=True, metadata={"serializable": True}) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: response = self.bedrock_client.converse(**self._base_params(prompt_stack)) @@ -67,6 +69,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: usage=Message.Usage(input_tokens=usage["inputTokens"], output_tokens=usage["outputTokens"]), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: response = self.bedrock_client.converse_stream(**self._base_params(prompt_stack)) diff --git a/griptape/drivers/prompt/amazon_sagemaker_jumpstart_prompt_driver.py b/griptape/drivers/prompt/amazon_sagemaker_jumpstart_prompt_driver.py index 09eadace5..c31e66abc 100644 --- a/griptape/drivers/prompt/amazon_sagemaker_jumpstart_prompt_driver.py +++ b/griptape/drivers/prompt/amazon_sagemaker_jumpstart_prompt_driver.py @@ -6,7 +6,7 @@ from attrs import Attribute, Factory, define, field from griptape.artifacts import TextArtifact -from griptape.common import DeltaMessage, Message, PromptStack, TextMessageContent +from griptape.common import DeltaMessage, Message, PromptStack, TextMessageContent, observable from griptape.drivers import BasePromptDriver from griptape.tokenizers import HuggingFaceTokenizer from griptape.utils import import_optional_dependency @@ -44,6 +44,7 @@ def validate_stream(self, _: Attribute, stream: bool) -> None: # noqa: FBT001 if stream: raise ValueError("streaming is not supported") + @observable def try_run(self, prompt_stack: PromptStack) -> Message: payload = { "inputs": self.prompt_stack_to_string(prompt_stack), @@ -81,6 +82,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: usage=Message.Usage(input_tokens=input_tokens, output_tokens=output_tokens), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: raise NotImplementedError("streaming is not supported") diff --git a/griptape/drivers/prompt/anthropic_prompt_driver.py b/griptape/drivers/prompt/anthropic_prompt_driver.py index 14be9a26b..ae50bc59e 100644 --- a/griptape/drivers/prompt/anthropic_prompt_driver.py +++ b/griptape/drivers/prompt/anthropic_prompt_driver.py @@ -27,6 +27,7 @@ TextDeltaMessageContent, TextMessageContent, ToolAction, + observable, ) from griptape.drivers import BasePromptDriver from griptape.tokenizers import AnthropicTokenizer, BaseTokenizer @@ -70,6 +71,7 @@ class AnthropicPromptDriver(BasePromptDriver): use_native_tools: bool = field(default=True, kw_only=True, metadata={"serializable": True}) max_tokens: int = field(default=1000, kw_only=True, metadata={"serializable": True}) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: response = self.client.messages.create(**self._base_params(prompt_stack)) @@ -79,6 +81,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: usage=Message.Usage(input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: events = self.client.messages.create(**self._base_params(prompt_stack), stream=True) diff --git a/griptape/drivers/prompt/base_prompt_driver.py b/griptape/drivers/prompt/base_prompt_driver.py index f2a5522d6..0a67b4f4e 100644 --- a/griptape/drivers/prompt/base_prompt_driver.py +++ b/griptape/drivers/prompt/base_prompt_driver.py @@ -14,6 +14,7 @@ PromptStack, TextDeltaMessageContent, TextMessageContent, + observable, ) from griptape.events import CompletionChunkEvent, FinishPromptEvent, StartPromptEvent from griptape.mixins import ExponentialBackoffMixin, SerializableMixin @@ -65,6 +66,7 @@ def after_run(self, result: Message) -> None: ), ) + @observable(tags=["PromptDriver.run()"]) def run(self, prompt_stack: PromptStack) -> Message: for attempt in self.retrying(): with attempt: diff --git a/griptape/drivers/prompt/cohere_prompt_driver.py b/griptape/drivers/prompt/cohere_prompt_driver.py index 0a28a9c59..ff1a8b482 100644 --- a/griptape/drivers/prompt/cohere_prompt_driver.py +++ b/griptape/drivers/prompt/cohere_prompt_driver.py @@ -17,6 +17,7 @@ TextDeltaMessageContent, TextMessageContent, ToolAction, + observable, ) from griptape.common.prompt_stack.contents.action_call_delta_message_content import ActionCallDeltaMessageContent from griptape.drivers import BasePromptDriver @@ -53,6 +54,7 @@ class CoherePromptDriver(BasePromptDriver): force_single_step: bool = field(default=False, kw_only=True, metadata={"serializable": True}) use_native_tools: bool = field(default=True, kw_only=True, metadata={"serializable": True}) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: result = self.client.chat(**self._base_params(prompt_stack)) usage = result.meta.tokens @@ -63,6 +65,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: usage=Message.Usage(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: result = self.client.chat_stream(**self._base_params(prompt_stack)) diff --git a/griptape/drivers/prompt/dummy_prompt_driver.py b/griptape/drivers/prompt/dummy_prompt_driver.py index c26d53794..9ed1f45ec 100644 --- a/griptape/drivers/prompt/dummy_prompt_driver.py +++ b/griptape/drivers/prompt/dummy_prompt_driver.py @@ -4,6 +4,7 @@ from attrs import Factory, define, field +from griptape.common import observable from griptape.drivers import BasePromptDriver from griptape.exceptions import DummyException from griptape.tokenizers import DummyTokenizer @@ -19,8 +20,10 @@ class DummyPromptDriver(BasePromptDriver): model: None = field(init=False, default=None, kw_only=True) tokenizer: DummyTokenizer = field(default=Factory(lambda: DummyTokenizer()), kw_only=True) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: raise DummyException(__class__.__name__, "try_run") + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: raise DummyException(__class__.__name__, "try_stream") diff --git a/griptape/drivers/prompt/google_prompt_driver.py b/griptape/drivers/prompt/google_prompt_driver.py index c85835826..3a32ebca7 100644 --- a/griptape/drivers/prompt/google_prompt_driver.py +++ b/griptape/drivers/prompt/google_prompt_driver.py @@ -20,6 +20,7 @@ TextDeltaMessageContent, TextMessageContent, ToolAction, + observable, ) from griptape.drivers import BasePromptDriver from griptape.tokenizers import BaseTokenizer, GoogleTokenizer @@ -62,6 +63,7 @@ class GooglePromptDriver(BasePromptDriver): use_native_tools: bool = field(default=True, kw_only=True, metadata={"serializable": True}) tool_choice: str = field(default="auto", kw_only=True, metadata={"serializable": True}) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: messages = self.__to_google_messages(prompt_stack) response: GenerateContentResponse = self.model_client.generate_content( @@ -80,6 +82,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: ), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: messages = self.__to_google_messages(prompt_stack) response: GenerateContentResponse = self.model_client.generate_content( diff --git a/griptape/drivers/prompt/huggingface_hub_prompt_driver.py b/griptape/drivers/prompt/huggingface_hub_prompt_driver.py index 072b01ab5..71244ddf0 100644 --- a/griptape/drivers/prompt/huggingface_hub_prompt_driver.py +++ b/griptape/drivers/prompt/huggingface_hub_prompt_driver.py @@ -4,7 +4,7 @@ from attrs import Factory, define, field -from griptape.common import DeltaMessage, Message, PromptStack, TextDeltaMessageContent +from griptape.common import DeltaMessage, Message, PromptStack, TextDeltaMessageContent, observable from griptape.drivers import BasePromptDriver from griptape.tokenizers import HuggingFaceTokenizer from griptape.utils import import_optional_dependency @@ -50,6 +50,7 @@ class HuggingFaceHubPromptDriver(BasePromptDriver): kw_only=True, ) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: prompt = self.prompt_stack_to_string(prompt_stack) @@ -68,6 +69,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: usage=Message.Usage(input_tokens=input_tokens, output_tokens=output_tokens), ) + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: prompt = self.prompt_stack_to_string(prompt_stack) diff --git a/griptape/drivers/prompt/huggingface_pipeline_prompt_driver.py b/griptape/drivers/prompt/huggingface_pipeline_prompt_driver.py index 992aff918..fb475bdd7 100644 --- a/griptape/drivers/prompt/huggingface_pipeline_prompt_driver.py +++ b/griptape/drivers/prompt/huggingface_pipeline_prompt_driver.py @@ -5,7 +5,7 @@ from attrs import Factory, define, field from griptape.artifacts import TextArtifact -from griptape.common import DeltaMessage, Message, PromptStack, TextMessageContent +from griptape.common import DeltaMessage, Message, PromptStack, TextMessageContent, observable from griptape.drivers import BasePromptDriver from griptape.tokenizers import HuggingFaceTokenizer from griptape.utils import import_optional_dependency @@ -47,6 +47,7 @@ class HuggingFacePipelinePromptDriver(BasePromptDriver): ), ) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: messages = self._prompt_stack_to_messages(prompt_stack) @@ -75,6 +76,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: else: raise Exception("invalid output format") + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: raise NotImplementedError("streaming is not supported") diff --git a/griptape/drivers/prompt/ollama_prompt_driver.py b/griptape/drivers/prompt/ollama_prompt_driver.py index fb1e28e87..ea4f8b344 100644 --- a/griptape/drivers/prompt/ollama_prompt_driver.py +++ b/griptape/drivers/prompt/ollama_prompt_driver.py @@ -13,6 +13,7 @@ PromptStack, TextDeltaMessageContent, TextMessageContent, + observable, ) from griptape.drivers import BasePromptDriver from griptape.tokenizers import SimpleTokenizer @@ -61,6 +62,7 @@ class OllamaPromptDriver(BasePromptDriver): kw_only=True, ) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: response = self.client.chat(**self._base_params(prompt_stack)) @@ -72,6 +74,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: else: raise Exception("invalid model response") + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: stream = self.client.chat(**self._base_params(prompt_stack), stream=True) diff --git a/griptape/drivers/prompt/openai_chat_prompt_driver.py b/griptape/drivers/prompt/openai_chat_prompt_driver.py index 9c4ab9a74..c845aa6c5 100644 --- a/griptape/drivers/prompt/openai_chat_prompt_driver.py +++ b/griptape/drivers/prompt/openai_chat_prompt_driver.py @@ -21,6 +21,7 @@ TextDeltaMessageContent, TextMessageContent, ToolAction, + observable, ) from griptape.drivers import BasePromptDriver from griptape.tokenizers import BaseTokenizer, OpenAiTokenizer @@ -88,6 +89,7 @@ class OpenAiChatPromptDriver(BasePromptDriver): kw_only=True, ) + @observable def try_run(self, prompt_stack: PromptStack) -> Message: result = self.client.chat.completions.create(**self._base_params(prompt_stack)) @@ -105,6 +107,7 @@ def try_run(self, prompt_stack: PromptStack) -> Message: else: raise Exception("Completion with more than one choice is not supported yet.") + @observable def try_stream(self, prompt_stack: PromptStack) -> Iterator[DeltaMessage]: result = self.client.chat.completions.create(**self._base_params(prompt_stack), stream=True) diff --git a/griptape/observability/__init__.py b/griptape/observability/__init__.py new file mode 100644 index 000000000..a17bb63f6 --- /dev/null +++ b/griptape/observability/__init__.py @@ -0,0 +1,3 @@ +from .observability import Observability + +__all__ = ["Observability"] diff --git a/griptape/observability/observability.py b/griptape/observability/observability.py new file mode 100644 index 000000000..1cbc589eb --- /dev/null +++ b/griptape/observability/observability.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from attrs import define, field + +from griptape.common import Observable +from griptape.drivers import BaseObservabilityDriver, NoOpObservabilityDriver + +_no_op_observability_driver = NoOpObservabilityDriver() +_global_observability_driver: Optional[BaseObservabilityDriver] = None + +if TYPE_CHECKING: + from types import TracebackType + + from griptape.common import Observable + + +@define +class Observability: + observability_driver: BaseObservabilityDriver = field(kw_only=True) + + @staticmethod + def get_global_driver() -> Optional[BaseObservabilityDriver]: + global _global_observability_driver + return _global_observability_driver + + @staticmethod + def set_global_driver(driver: Optional[BaseObservabilityDriver]) -> None: + global _global_observability_driver + _global_observability_driver = driver + + @staticmethod + def observe(call: Observable.Call) -> Any: + driver = Observability.get_global_driver() or _no_op_observability_driver + return driver.observe(call) + + @staticmethod + def get_span_id() -> Optional[str]: + driver = Observability.get_global_driver() or _no_op_observability_driver + return driver.get_span_id() + + def __enter__(self) -> None: + if Observability.get_global_driver() is not None: + raise ValueError("Observability driver already set.") + Observability.set_global_driver(self.observability_driver) + self.observability_driver.__enter__() + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_value: Optional[BaseException], + exc_traceback: Optional[TracebackType], + ) -> bool: + Observability.set_global_driver(None) + self.observability_driver.__exit__(exc_type, exc_value, exc_traceback) + return False diff --git a/griptape/structures/agent.py b/griptape/structures/agent.py index 693a98c34..b133a7b6b 100644 --- a/griptape/structures/agent.py +++ b/griptape/structures/agent.py @@ -5,6 +5,7 @@ from attrs import Attribute, define, field from griptape.artifacts.text_artifact import TextArtifact +from griptape.common import observable from griptape.memory.structure import Run from griptape.structures import Structure from griptape.tasks import PromptTask, ToolkitTask @@ -57,6 +58,7 @@ def add_tasks(self, *tasks: BaseTask) -> list[BaseTask]: raise ValueError("Agents can only have one task.") return super().add_tasks(*tasks) + @observable def try_run(self, *args) -> Agent: self.task.execute() diff --git a/griptape/structures/pipeline.py b/griptape/structures/pipeline.py index b768cf6c6..0aed369bb 100644 --- a/griptape/structures/pipeline.py +++ b/griptape/structures/pipeline.py @@ -5,6 +5,7 @@ from attrs import define from griptape.artifacts import ErrorArtifact +from griptape.common import observable from griptape.memory.structure import Run from griptape.structures import Structure @@ -45,6 +46,7 @@ def insert_task(self, parent_task: BaseTask, task: BaseTask) -> BaseTask: return task + @observable def try_run(self, *args) -> Pipeline: self.__run_from_task(self.input_task) diff --git a/griptape/structures/structure.py b/griptape/structures/structure.py index 13c247824..0a296f980 100644 --- a/griptape/structures/structure.py +++ b/griptape/structures/structure.py @@ -10,6 +10,7 @@ from rich.logging import RichHandler from griptape.artifacts import BaseArtifact, BlobArtifact, TextArtifact +from griptape.common import observable from griptape.config import BaseStructureConfig, OpenAiStructureConfig, StructureConfig from griptape.drivers import BaseEmbeddingDriver, BasePromptDriver, OpenAiChatPromptDriver, OpenAiEmbeddingDriver from griptape.drivers.vector.local_vector_store_driver import LocalVectorStoreDriver @@ -255,6 +256,7 @@ def resolve_relationships(self) -> None: if task.id not in child.parent_ids: child.parent_ids.append(task.id) + @observable def before_run(self, args: Any) -> None: self._execution_args = args @@ -270,6 +272,7 @@ def before_run(self, args: Any) -> None: self.resolve_relationships() + @observable def after_run(self) -> None: self.publish_event( FinishStructureRunEvent( @@ -283,6 +286,7 @@ def after_run(self) -> None: @abstractmethod def add_task(self, task: BaseTask) -> BaseTask: ... + @observable def run(self, *args) -> Structure: self.before_run(args) diff --git a/griptape/structures/workflow.py b/griptape/structures/workflow.py index 32485af7b..2ecfb8676 100644 --- a/griptape/structures/workflow.py +++ b/griptape/structures/workflow.py @@ -7,6 +7,7 @@ from graphlib import TopologicalSorter from griptape.artifacts import ErrorArtifact +from griptape.common import observable from griptape.memory.structure import Run from griptape.structures import Structure @@ -82,6 +83,7 @@ def insert_task( return task + @observable def try_run(self, *args) -> Workflow: exit_loop = False diff --git a/griptape/tools/base_tool.py b/griptape/tools/base_tool.py index 60eb10cda..a728c538e 100644 --- a/griptape/tools/base_tool.py +++ b/griptape/tools/base_tool.py @@ -13,6 +13,7 @@ from schema import Literal, Or, Schema from griptape.artifacts import BaseArtifact, ErrorArtifact, InfoArtifact, TextArtifact +from griptape.common import observable from griptape.mixins import ActivityMixin if TYPE_CHECKING: @@ -121,6 +122,7 @@ def execute(self, activity: Callable, subtask: ActionsSubtask, action: ToolActio def before_run(self, activity: Callable, subtask: ActionsSubtask, action: ToolAction) -> Optional[dict]: return action.input + @observable(tags=["Tool.run()"]) def run( self, activity: Callable, diff --git a/poetry.lock b/poetry.lock index d654c99f5..3ae72743c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1188,6 +1188,23 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] + [[package]] name = "distlib" version = "0.3.8" @@ -3572,6 +3589,131 @@ develop = ["black", "botocore", "coverage (<8.0.0)", "jinja2", "mock", "myst-par docs = ["aiohttp (>=3,<4)", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] kerberos = ["requests-kerberos"] +[[package]] +name = "opentelemetry-api" +version = "1.25.0" +description = "OpenTelemetry Python API" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_api-1.25.0-py3-none-any.whl", hash = "sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737"}, + {file = "opentelemetry_api-1.25.0.tar.gz", hash = "sha256:77c4985f62f2614e42ce77ee4c9da5fa5f0bc1e1821085e9a47533a9323ae869"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +importlib-metadata = ">=6.0,<=7.1" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.25.0" +description = "OpenTelemetry Protobuf encoding" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_exporter_otlp_proto_common-1.25.0-py3-none-any.whl", hash = "sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.25.0.tar.gz", hash = "sha256:c93f4e30da4eee02bacd1e004eb82ce4da143a2f8e15b987a9f603e0a85407d3"}, +] + +[package.dependencies] +opentelemetry-proto = "1.25.0" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.25.0" +description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_exporter_otlp_proto_http-1.25.0-py3-none-any.whl", hash = "sha256:2eca686ee11b27acd28198b3ea5e5863a53d1266b91cda47c839d95d5e0541a6"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.25.0.tar.gz", hash = "sha256:9f8723859e37c75183ea7afa73a3542f01d0fd274a5b97487ea24cb683d7d684"}, +] + +[package.dependencies] +deprecated = ">=1.2.6" +googleapis-common-protos = ">=1.52,<2.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.25.0" +opentelemetry-proto = "1.25.0" +opentelemetry-sdk = ">=1.25.0,<1.26.0" +requests = ">=2.7,<3.0" + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.46b0" +description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_instrumentation-0.46b0-py3-none-any.whl", hash = "sha256:89cd721b9c18c014ca848ccd11181e6b3fd3f6c7669e35d59c48dc527408c18b"}, + {file = "opentelemetry_instrumentation-0.46b0.tar.gz", hash = "sha256:974e0888fb2a1e01c38fbacc9483d024bb1132aad92d6d24e2e5543887a7adda"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.4,<2.0" +setuptools = ">=16.0" +wrapt = ">=1.0.0,<2.0.0" + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.46b0" +description = "Thread context propagation support for OpenTelemetry" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_instrumentation_threading-0.46b0-py3-none-any.whl", hash = "sha256:60fe4e86a8e399c187eeafccfeeefa07d5b9d4382bc9c4f52ab5436d6bb244bf"}, + {file = "opentelemetry_instrumentation_threading-0.46b0.tar.gz", hash = "sha256:938dacb52b2ac1114678d146d2ef2d0044f3e32ec8b2045db36d709de6c95548"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" +opentelemetry-instrumentation = "0.46b0" +wrapt = ">=1.0.0,<2.0.0" + +[[package]] +name = "opentelemetry-proto" +version = "1.25.0" +description = "OpenTelemetry Python Proto" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_proto-1.25.0-py3-none-any.whl", hash = "sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f"}, + {file = "opentelemetry_proto-1.25.0.tar.gz", hash = "sha256:35b6ef9dc4a9f7853ecc5006738ad40443701e52c26099e197895cbda8b815a3"}, +] + +[package.dependencies] +protobuf = ">=3.19,<5.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.25.0" +description = "OpenTelemetry Python SDK" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_sdk-1.25.0-py3-none-any.whl", hash = "sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9"}, + {file = "opentelemetry_sdk-1.25.0.tar.gz", hash = "sha256:ce7fc319c57707ef5bf8b74fb9f8ebdb8bfafbe11898410e0d2a761d08a98ec7"}, +] + +[package.dependencies] +opentelemetry-api = "1.25.0" +opentelemetry-semantic-conventions = "0.46b0" +typing-extensions = ">=3.7.4" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.46b0" +description = "OpenTelemetry Semantic Conventions" +optional = true +python-versions = ">=3.8" +files = [ + {file = "opentelemetry_semantic_conventions-0.46b0-py3-none-any.whl", hash = "sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07"}, + {file = "opentelemetry_semantic_conventions-0.46b0.tar.gz", hash = "sha256:fbc982ecbb6a6e90869b15c1673be90bd18c8a56ff1cffc0864e38e2edffaefa"}, +] + +[package.dependencies] +opentelemetry-api = "1.25.0" + [[package]] name = "packaging" version = "24.0" @@ -4726,6 +4868,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -6399,6 +6542,85 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "wrapt" +version = "1.16.0" +description = "Module for decorators, wrappers and monkey patching." +optional = true +python-versions = ">=3.6" +files = [ + {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, + {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, + {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, + {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, + {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, + {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, + {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, + {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, + {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, + {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, + {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, + {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, + {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, + {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, + {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, + {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, + {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, + {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, + {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, + {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, + {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, + {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, + {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, + {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, + {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, + {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, + {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, + {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, + {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, + {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, + {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, + {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, + {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, + {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, + {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, + {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, + {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, +] + [[package]] name = "xmltodict" version = "0.13.0" @@ -6529,7 +6751,7 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] -all = ["anthropic", "beautifulsoup4", "boto3", "cohere", "duckduckgo-search", "elevenlabs", "filetype", "google-generativeai", "mail-parser", "markdownify", "marqo", "ollama", "opensearch-py", "pandas", "pgvector", "pillow", "pinecone-client", "playwright", "psycopg2-binary", "pusher", "pymongo", "pypdf", "qdrant-client", "redis", "snowflake-sqlalchemy", "sqlalchemy-redshift", "trafilatura", "transformers", "voyageai"] +all = ["anthropic", "beautifulsoup4", "boto3", "cohere", "duckduckgo-search", "elevenlabs", "filetype", "google-generativeai", "mail-parser", "markdownify", "marqo", "ollama", "opensearch-py", "opentelemetry-api", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-instrumentation", "opentelemetry-instrumentation-threading", "opentelemetry-sdk", "pandas", "pgvector", "pillow", "pinecone-client", "playwright", "psycopg2-binary", "pusher", "pymongo", "pypdf", "qdrant-client", "redis", "snowflake-sqlalchemy", "sqlalchemy-redshift", "trafilatura", "transformers", "voyageai"] drivers-embedding-amazon-bedrock = ["boto3"] drivers-embedding-amazon-sagemaker = ["boto3"] drivers-embedding-cohere = ["cohere"] @@ -6542,6 +6764,8 @@ drivers-event-listener-amazon-sqs = ["boto3"] drivers-event-listener-pusher = ["pusher"] drivers-memory-conversation-amazon-dynamodb = ["boto3"] drivers-memory-conversation-redis = ["redis"] +drivers-observability-griptape-cloud = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-instrumentation", "opentelemetry-instrumentation-threading", "opentelemetry-sdk"] +drivers-observability-opentelemetry = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-instrumentation", "opentelemetry-instrumentation-threading", "opentelemetry-sdk"] drivers-prompt-amazon-bedrock = ["anthropic", "boto3"] drivers-prompt-amazon-sagemaker = ["boto3", "transformers"] drivers-prompt-anthropic = ["anthropic"] @@ -6574,4 +6798,4 @@ loaders-pdf = ["pypdf"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "a49e3177bd216b5af2ffe2f213d1e2d8081f23ef33e4c71ecc16c673287823fa" +content-hash = "9bd25bfc6e645b80acebb1266659832642799443b5f7314dc42487f4e67f1e42" diff --git a/pyproject.toml b/pyproject.toml index fabce497e..510209d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,11 @@ qdrant-client = { version = ">=1.9.1", optional = true } pusher = {version = "^3.3.2", optional = true} ollama = {version = "^0.2.1", optional = true} duckduckgo-search = {version = "^6.1.12", optional = true} +opentelemetry-sdk = {version = "^1.25.0", optional = true} +opentelemetry-api = {version = "^1.25.0", optional = true} +opentelemetry-instrumentation = {version = "^0.46b0", optional = true} +opentelemetry-instrumentation-threading = {version = "^0.46b0", optional = true} +opentelemetry-exporter-otlp-proto-http = {version = "^1.25.0", optional = true} # loaders pandas = {version = "^1.3", optional = true} @@ -108,6 +113,21 @@ drivers-event-listener-pusher = ["pusher"] drivers-rerank-cohere = ["cohere"] +drivers-observability-opentelemetry = [ + "opentelemetry-sdk", + "opentelemetry-api", + "opentelemetry-instrumentation", + "opentelemetry-instrumentation-threading", + "opentelemetry-exporter-otlp-proto-http", +] +drivers-observability-griptape-cloud = [ + "opentelemetry-sdk", + "opentelemetry-api", + "opentelemetry-instrumentation", + "opentelemetry-instrumentation-threading", + "opentelemetry-exporter-otlp-proto-http", +] + loaders-dataframe = ["pandas"] loaders-pdf = ["pypdf"] loaders-image = ["pillow"] @@ -142,6 +162,11 @@ all = [ "pusher", "ollama", "duckduckgo-search", + "opentelemetry-sdk", + "opentelemetry-api", + "opentelemetry-instrumentation", + "opentelemetry-instrumentation-threading", + "opentelemetry-exporter-otlp-proto-http", # loaders "pandas", diff --git a/tests/unit/common/test_observable.py b/tests/unit/common/test_observable.py new file mode 100644 index 000000000..f48c3086c --- /dev/null +++ b/tests/unit/common/test_observable.py @@ -0,0 +1,238 @@ +from unittest.mock import call + +import pytest + +import griptape.observability.observability as observability +from griptape.common.observable import Observable + + +class TestObservable: + @pytest.fixture() + def observe_spy(self, mocker): + return mocker.spy(observability.Observability, "observe") + + def test_observable_function_no_parenthesis(self, observe_spy): + from griptape.common import observable + + @observable + def bar(*args, **kwargs): + """Bar's docstring.""" + if args: + return args[0] + + assert bar() is None + assert bar("a") == "a" + assert bar("b", "2") == "b" + assert bar("c", x="y") == "c" + + original_bar = bar.__wrapped__ + assert bar.__name__ == original_bar.__name__ + assert bar.__name__ == "bar" + assert bar.__doc__ == original_bar.__doc__ + assert bar.__doc__ == "Bar's docstring." + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call(Observable.Call(func=original_bar, args=())), + call(Observable.Call(func=original_bar, args=("a",))), + call(Observable.Call(func=original_bar, args=("b", "2"))), + call(Observable.Call(func=original_bar, args=("c",), kwargs={"x": "y"})), + ] + ) + + def test_observable_function_empty_parenthesis(self, observe_spy): + from griptape.common import observable + + @observable() + def bar(*args, **kwargs): + if args: + return args[0] + + assert bar() is None + assert bar("a") == "a" + assert bar("b", "2") == "b" + assert bar("c", x="y") == "c" + + original_bar = bar.__wrapped__ + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call(Observable.Call(func=original_bar, args=())), + call(Observable.Call(func=original_bar, args=("a",))), + call(Observable.Call(func=original_bar, args=("b", "2"))), + call(Observable.Call(func=original_bar, args=("c",), kwargs={"x": "y"})), + ] + ) + + def test_observable_function_args(self, observe_spy): + from griptape.common import observable + + @observable("one", 2, {"th": "ree"}, a="b", b=6) + def bar(*args, **kwargs): + if args: + return args[0] + + assert bar() is None + assert bar("a") == "a" + assert bar("b", "2") == "b" + assert bar("c", x="y") == "c" + + original_bar = bar.__wrapped__ + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call( + Observable.Call( + func=original_bar, + args=(), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + args=("a",), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + args=("b", "2"), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + args=("c",), + kwargs={"x": "y"}, + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + ] + ) + + def test_observable_method_no_parenthesis(self, observe_spy): + from griptape.common import observable + + class Foo: + @observable + def bar(self, *args, **kwargs): + if args: + return args[0] + return None + + foo = Foo() + assert foo.bar() is None + assert foo.bar("a") == "a" + assert foo.bar("b", "2") == "b" + assert foo.bar("c", x="y") == "c" + + original_bar = foo.bar.__wrapped__ + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call(Observable.Call(func=original_bar, instance=foo, args=())), + call(Observable.Call(func=original_bar, instance=foo, args=("a",))), + call(Observable.Call(func=original_bar, instance=foo, args=("b", "2"))), + call(Observable.Call(func=original_bar, instance=foo, args=("c",), kwargs={"x": "y"})), + ] + ) + + def test_observable_method_empty_parenthesis(self, observe_spy): + from griptape.common import observable + + class Foo: + @observable() + def bar(self, *args, **kwargs): + if args: + return args[0] + return None + + foo = Foo() + assert foo.bar() is None + assert foo.bar("a") == "a" + assert foo.bar("b", "2") == "b" + assert foo.bar("c", x="y") == "c" + + original_bar = foo.bar.__wrapped__ + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call(Observable.Call(func=original_bar, instance=foo, args=())), + call(Observable.Call(func=original_bar, instance=foo, args=("a",))), + call(Observable.Call(func=original_bar, instance=foo, args=("b", "2"))), + call(Observable.Call(func=original_bar, instance=foo, args=("c",), kwargs={"x": "y"})), + ] + ) + + def test_observable_method_args(self, observe_spy): + from griptape.common import observable + + class Foo: + @observable("one", 2, {"th": "ree"}, a="b", b=6) + def bar(self, *args, **kwargs): + if args: + return args[0] + return None + + foo = Foo() + assert foo.bar() is None + assert foo.bar("a") == "a" + assert foo.bar("b", "2") == "b" + assert foo.bar("c", x="y") == "c" + + original_bar = foo.bar.__wrapped__ + + assert observe_spy.call_count == 4 + observe_spy.assert_has_calls( + [ + call( + Observable.Call( + func=original_bar, + instance=foo, + args=(), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + instance=foo, + args=("a",), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + instance=foo, + args=("b", "2"), + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + call( + Observable.Call( + func=original_bar, + instance=foo, + args=("c",), + kwargs={"x": "y"}, + decorator_args=("one", 2, {"th": "ree"}), + decorator_kwargs={"a": "b", "b": 6}, + ) + ), + ] + ) diff --git a/tests/unit/drivers/event_listener/test_griptape_cloud_event_listener_driver.py b/tests/unit/drivers/event_listener/test_griptape_cloud_event_listener_driver.py index 5db78b76f..1cd198756 100644 --- a/tests/unit/drivers/event_listener/test_griptape_cloud_event_listener_driver.py +++ b/tests/unit/drivers/event_listener/test_griptape_cloud_event_listener_driver.py @@ -1,9 +1,10 @@ import os -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest from griptape.drivers.event_listener.griptape_cloud_event_listener_driver import GriptapeCloudEventListenerDriver +from griptape.observability.observability import Observability from tests.mocks.mock_event import MockEvent @@ -42,6 +43,30 @@ def test_init(self, driver): assert driver.api_key == "foo bar" assert driver.structure_run_id == "bar baz" + def test_publish_event_without_span_id(self, mock_post, driver): + event = MockEvent() + driver.publish_event(event, flush=True) + + mock_post.assert_called_with( + url="https://cloud123.griptape.ai/api/structure-runs/bar baz/events", + json=[event.to_dict()], + headers={"Authorization": "Bearer foo bar"}, + ) + + def test_publish_event_with_span_id(self, mock_post, driver): + event = MockEvent() + observability_driver = MagicMock() + observability_driver.get_span_id.return_value = "test" + + with Observability(observability_driver=observability_driver): + driver.publish_event(event, flush=True) + + mock_post.assert_called_with( + url="https://cloud123.griptape.ai/api/structure-runs/bar baz/events", + json=[{**event.to_dict(), "span_id": "test"}], + headers={"Authorization": "Bearer foo bar"}, + ) + def test_try_publish_event_payload(self, mock_post, driver): event = MockEvent() driver.try_publish_event_payload(event.to_dict()) diff --git a/tests/unit/drivers/observability/__init__.py b/tests/unit/drivers/observability/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py b/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py new file mode 100644 index 000000000..98b5ea043 --- /dev/null +++ b/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py @@ -0,0 +1,250 @@ +import pytest +from opentelemetry.sdk.trace import Event, ReadableSpan +from opentelemetry.trace import SpanContext, Status, StatusCode + +from griptape.common import Observable +from griptape.drivers import GriptapeCloudObservabilityDriver +from tests.utils.expected_spans import ExpectedSpan, ExpectedSpans + + +class TestGriptapeCloudObservabilityDriver: + @pytest.fixture() + def driver(self): + return GriptapeCloudObservabilityDriver( + base_url="http://base-url:1234", api_key="api-key", structure_run_id="structure-run-id" + ) + + @pytest.fixture(autouse=True) + def mock_span_exporter_class(self, mocker): + return mocker.patch( + "griptape.drivers.observability.griptape_cloud_observability_driver.GriptapeCloudObservabilityDriver.SpanExporter" + ) + + @pytest.fixture() + def mock_span_exporter(self, mock_span_exporter_class): + return mock_span_exporter_class.return_value + + def test_init(self, mock_span_exporter_class, mock_span_exporter): + GriptapeCloudObservabilityDriver( + base_url="http://base-url:1234", api_key="api-key", structure_run_id="structure-run-id" + ) + + assert mock_span_exporter_class.call_count == 1 + mock_span_exporter_class.assert_called_once_with( + base_url="http://base-url:1234", + api_key="api-key", + headers={"Authorization": "Bearer api-key"}, + structure_run_id="structure-run-id", + ) + + mock_span_exporter.export.assert_not_called() + + def test_context_manager_pass(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans(spans=[ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK)]) + + with driver: + pass + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with driver: + pass + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_pass_exc(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans(spans=[ExpectedSpan(name="main", parent=None, status_code=StatusCode.ERROR)]) + + with pytest.raises(Exception, match="Boom"), driver: + raise Exception("Boom") + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with pytest.raises(Exception, match="Boom"), driver: + raise Exception("Boom") + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_observe_exception(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK), + ExpectedSpan(name="func()", parent="main", status_code=StatusCode.OK), + ExpectedSpan(name="Klass.method()", parent="main", status_code=StatusCode.OK), + ] + ) + + def func(word: str): + return word + " you" + + class Klass: + def method(self, word: str): + return word + " yous" + + instance = Klass() + + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_observe(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK), + ExpectedSpan(name="func()", parent="main", status_code=StatusCode.OK), + ExpectedSpan(name="Klass.method()", parent="main", status_code=StatusCode.OK), + ] + ) + + def func(word: str): + return word + " you" + + class Klass: + def method(self, word: str): + return word + " yous" + + instance = Klass() + + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + +class TestGriptapeCloudObservabilityDriverSpanExporter: + @pytest.fixture() + def mock_post(self, mocker): + return mocker.patch("requests.post") + + def test_span_exporter_export(self, mock_post): + exporter = GriptapeCloudObservabilityDriver.SpanExporter( + base_url="http://base-url:1234", + api_key="api-key", + headers={"Authorization": "Bearer api-key"}, + structure_run_id="structure-run-id", + ) + + exporter.export( + [ + ReadableSpan( + name="main", + parent=None, + context=SpanContext(trace_id=1, span_id=2, is_remote=False), + start_time=3000, + end_time=4000, + attributes={"key": "value"}, + ), + ReadableSpan( + name="thing-1", + parent=SpanContext(trace_id=1, span_id=2, is_remote=False), + context=SpanContext(trace_id=1, span_id=3, is_remote=False), + start_time=8000, + end_time=9000, + status=Status(status_code=StatusCode.OK), + ), + ReadableSpan( + name="thing-2", + parent=SpanContext(trace_id=1, span_id=2, is_remote=False), + context=SpanContext(trace_id=1, span_id=3, is_remote=False), + start_time=8000, + end_time=9000, + status=Status(status_code=StatusCode.ERROR), + events=[ + Event( + timestamp=10000, + name="exception", + attributes={ + "exception.type": "Exception", + "exception.message": "Boom", + "exception.stacktrace": "Traceback (most recent call last) ...", + }, + ) + ], + ), + ] + ) + + mock_post.assert_called_once_with( + url="http://base-url:1234/api/structure-runs/structure-run-id/spans", + json=[ + { + "trace_id": "00000000-0000-0000-0000-000000000001", + "span_id": "00000000-0000-0000-0000-000000000002", + "parent_id": None, + "name": "main", + "start_time": "1970-01-01T00:00:00.000003Z", + "end_time": "1970-01-01T00:00:00.000004Z", + "status": "UNSET", + "attributes": {"key": "value"}, + "events": [], + }, + { + "trace_id": "00000000-0000-0000-0000-000000000001", + "span_id": "00000000-0000-0000-0000-000000000003", + "parent_id": "00000000-0000-0000-0000-000000000002", + "name": "thing-1", + "start_time": "1970-01-01T00:00:00.000008Z", + "end_time": "1970-01-01T00:00:00.000009Z", + "status": "OK", + "attributes": {}, + "events": [], + }, + { + "trace_id": "00000000-0000-0000-0000-000000000001", + "span_id": "00000000-0000-0000-0000-000000000003", + "parent_id": "00000000-0000-0000-0000-000000000002", + "name": "thing-2", + "start_time": "1970-01-01T00:00:00.000008Z", + "end_time": "1970-01-01T00:00:00.000009Z", + "status": "ERROR", + "attributes": {}, + "events": [ + { + "timestamp": "1970-01-01T00:00:00.000010Z", + "name": "exception", + "attributes": { + "exception.type": "Exception", + "exception.message": "Boom", + "exception.stacktrace": "Traceback (most recent call last) ...", + }, + } + ], + }, + ], + headers={"Authorization": "Bearer api-key"}, + ) diff --git a/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py b/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py new file mode 100644 index 000000000..44da64727 --- /dev/null +++ b/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py @@ -0,0 +1,190 @@ +from unittest.mock import MagicMock + +import pytest +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import StatusCode + +from griptape.common import Observable +from griptape.drivers import OpenTelemetryObservabilityDriver +from griptape.observability.observability import Observability +from griptape.structures.agent import Agent +from tests.mocks.mock_prompt_driver import MockPromptDriver +from tests.utils.expected_spans import ExpectedSpan, ExpectedSpans + + +class TestOpenTelemetryObservabilityDriver: + @pytest.fixture() + def mock_span_exporter(self): + return MagicMock() + + @pytest.fixture() + def span_processor(self, mock_span_exporter): + return BatchSpanProcessor(mock_span_exporter) + + @pytest.fixture() + def driver(self, span_processor): + return OpenTelemetryObservabilityDriver(service_name="test", span_processor=span_processor) + + def test_init(self, span_processor): + OpenTelemetryObservabilityDriver(service_name="test", span_processor=span_processor) + + def test_context_manager_pass(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans(spans=[ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK)]) + + with driver: + pass + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with driver: + pass + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_exception(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ExpectedSpan(name="main", parent=None, status_code=StatusCode.ERROR, exception=Exception("Boom"))] + ) + + with pytest.raises(Exception, match="Boom"), driver: + raise Exception("Boom") + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with pytest.raises(Exception, match="Boom"), driver: + raise Exception("Boom") + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_observe(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK), + ExpectedSpan(name="func()", parent="main", status_code=StatusCode.OK), + ExpectedSpan(name="Klass.method()", parent="main", status_code=StatusCode.OK), + ] + ) + + def func(word: str): + return word + " you" + + class Klass: + def method(self, word: str): + return word + " yous" + + instance = Klass() + + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + # Works second time too + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_observe_exception_function(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.ERROR, exception=Exception("Boom func")), + ExpectedSpan( + name="func()", parent="main", status_code=StatusCode.ERROR, exception=Exception("Boom func") + ), + ] + ) + + def func(word: str): + raise Exception("Boom func") + + with pytest.raises(Exception, match="Boom func"), driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + + def test_context_manager_observe_exception_method(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.ERROR, exception=Exception("Boom meth")), + ExpectedSpan( + name="Klass.method()", parent="main", status_code=StatusCode.ERROR, exception=Exception("Boom meth") + ), + ] + ) + + class Klass: + def method(self, word: str): + raise Exception("Boom meth") + + instance = Klass() + + # Works second time too + with pytest.raises(Exception, match="Boom meth"), driver: + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_observability_agent(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK), + ExpectedSpan(name="Agent.run()", parent="main", status_code=StatusCode.OK), + ExpectedSpan(name="Agent.before_run()", parent="Agent.run()", status_code=StatusCode.OK), + ExpectedSpan(name="Agent.try_run()", parent="Agent.run()", status_code=StatusCode.OK), + ExpectedSpan(name="MockPromptDriver.run()", parent="Agent.try_run()", status_code=StatusCode.OK), + ExpectedSpan(name="Agent.after_run()", parent="Agent.run()", status_code=StatusCode.OK), + ] + ) + + with Observability(observability_driver=driver): + agent = Agent(prompt_driver=MockPromptDriver()) + agent.run("Hi") + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() + + def test_context_manager_observe_adds_tags_attribute(self, driver, mock_span_exporter): + expected_spans = ExpectedSpans( + spans=[ + ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK), + ExpectedSpan( + name="func()", parent="main", status_code=StatusCode.OK, attributes={"tags": ("Foo.bar()",)} + ), + ] + ) + + def func(word: str): + return word + " you" + + with driver: + assert ( + driver.observe( + Observable.Call(func=func, instance=None, args=["Hi"], decorator_kwargs={"tags": ["Foo.bar()"]}) + ) + == "Hi you" + ) + + assert mock_span_exporter.export.call_count == 1 + mock_span_exporter.export.assert_called_with(expected_spans) + mock_span_exporter.export.reset_mock() diff --git a/tests/unit/observability/__init__.py b/tests/unit/observability/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/observability/test_observability.py b/tests/unit/observability/test_observability.py new file mode 100644 index 000000000..208a60a93 --- /dev/null +++ b/tests/unit/observability/test_observability.py @@ -0,0 +1,42 @@ +from unittest.mock import MagicMock + +import pytest + +import griptape.observability.observability as observability +from griptape.observability.observability import Observability + + +class TestObservability: + @pytest.fixture() + def mock_observability_driver(self): + return MagicMock() + + def test_init(self, mock_observability_driver): + assert observability._global_observability_driver is None + + Observability(observability_driver=mock_observability_driver) + + assert observability._global_observability_driver is None + + def test_context_manager(self, mock_observability_driver): + assert observability._global_observability_driver is None + + with Observability(observability_driver=mock_observability_driver): + assert observability._global_observability_driver is mock_observability_driver + mock_observability_driver.__enter__.assert_called_once_with() + + mock_observability_driver.__exit__.assert_called_once_with(None, None, None) + + assert observability._global_observability_driver is None + + def test_context_manager_exception(self, mock_observability_driver): + assert observability._global_observability_driver is None + + with pytest.raises(Exception, match="Boom") as e: # noqa: PT012, SIM117 + with Observability(observability_driver=mock_observability_driver): + assert observability._global_observability_driver is mock_observability_driver + mock_observability_driver.__enter__.assert_called_once_with() + raise Exception("Boom") + + mock_observability_driver.__exit__.assert_called_once_with(*e._excinfo) + assert observability._global_observability_driver is None diff --git a/tests/utils/expected_spans.py b/tests/utils/expected_spans.py new file mode 100644 index 000000000..98cb645db --- /dev/null +++ b/tests/utils/expected_spans.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from attrs import define, field +from opentelemetry.trace import SpanKind, StatusCode + +if TYPE_CHECKING: + from collections.abc import Sequence + + from opentelemetry.sdk.trace import ReadableSpan + + +@define +class ExpectedSpan: + name: str = field(kw_only=True) + parent: str = field(kw_only=True) + status_code: StatusCode = field(kw_only=True) + exception: Optional[Exception] = field(default=None, kw_only=True) + attributes: Optional[dict] = field(default=None, kw_only=True) + + +@define +class ExpectedSpans: + spans: list[ExpectedSpan] = field(kw_only=True) + + def __eq__(self, other_spans: Sequence[ReadableSpan]) -> bool: # noqa: C901 + # Has expected spans + span_names = [span.name for span in self.spans] + other_span_names = [span.name for span in other_spans] + if sorted(other_span_names) != sorted(span_names): + raise Exception(f"Expected spans {other_span_names} not found. Found: {span_names}") + + # Has valid trace id + trace_id = other_spans[0].context.trace_id + if not trace_id: + raise Exception(f"Trace id is not set on span {other_spans[0].name}") + + for span in other_spans: + # All have same trace id + if span.context.trace_id != trace_id: + raise Exception(f"Span {span.name} has different trace id than the rest") + + # All have kind set to internal + if span.kind != SpanKind.INTERNAL: + raise Exception(f"Span {span.name} is not of kind INTERNAL") + + other_span_by_name = {span.name: span for span in other_spans} + span_by_name = {span.name: span for span in self.spans} + expected_status_codes = {span.name: span.status_code for span in self.spans} + for span_name, status_code in expected_status_codes.items(): + span = other_span_by_name[span_name] + actual_status_code = span.status.status_code + if actual_status_code != status_code: + raise Exception(f"Span {span_name} has code {actual_status_code} instead of {status_code}") + + exception = span_by_name[span_name].exception + if exception: + event = span.events[0] + exc_type = event.attributes.get("exception.type") + exc_message = event.attributes.get("exception.message") + exc_stacktrace = event.attributes.get("exception.stacktrace") + + if exc_type != "Exception": + raise Exception(f"Span {span_name} does not have exception type Exception") + if exc_message != str(exception): + raise Exception(f"Span {span_name} does not have exception message {exception}") + if not exc_stacktrace: + raise Exception(f"Span {span_name} has no stacktrace") + + expected_parents = {span.name: span.parent for span in self.spans} + for child, parent in expected_parents.items(): + actual_parent = other_span_by_name[child].parent.span_id if other_span_by_name[child].parent else None + expected_parent = parent and other_span_by_name[parent].context.span_id + if actual_parent != expected_parent: + raise Exception(f"Span {child} has wrong parent") + + expected_attributes_by_span_name = {span.name: span.attributes for span in self.spans} + for span_name, expected_attributes in expected_attributes_by_span_name.items(): + other_span = other_span_by_name[span_name] + if expected_attributes is not None and other_span.attributes != expected_attributes: + raise Exception( + f"Span {span_name} has attributes {other_span.attributes} instead of {expected_attributes}" + ) + + return True From f97b71b194e6574800e96d2848f9e9115e78cbbf Mon Sep 17 00:00:00 2001 From: dylanholmes <4370153+dylanholmes@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:56:37 -0700 Subject: [PATCH 2/5] pr comments --- docs/griptape-framework/drivers/observability-drivers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/griptape-framework/drivers/observability-drivers.md b/docs/griptape-framework/drivers/observability-drivers.md index 41cbf26af..22ae85a5e 100644 --- a/docs/griptape-framework/drivers/observability-drivers.md +++ b/docs/griptape-framework/drivers/observability-drivers.md @@ -11,7 +11,7 @@ Observability Drivers are used by [Observability](../structures/observability.md The Griptape Cloud Observability Driver instruments `@observable` functions and methods with metrics and traces for use with the Griptape Cloud. !!! note - For the Griptape Cloud Observability Driver to function as intended, it must be run from within either a Managed Structure on Griptape Cloud, + For the Griptape Cloud Observability Driver to function as intended, it must be run from within either a Managed Structure on Griptape Cloud or locally via the [Skatepark Emulator](https://github.com/griptape-ai/griptape-cli?tab=readme-ov-file#skatepark-emulator). Here is an example of how to use the `GriptapeCloudObservabilityDriver` with the `Observability` context manager to send the telemetry to Griptape Cloud: @@ -45,7 +45,7 @@ from griptape.observability import Observability from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor observability_driver = OpenTelemetryObservabilityDriver( - service_name="my-gt-app", + service_name="name-an-animal", span_processor=BatchSpanProcessor(ConsoleSpanExporter()) ) From 842c392f56079ccdb833afd7cfc6aa24791e790f Mon Sep 17 00:00:00 2001 From: dylanholmes <4370153+dylanholmes@users.noreply.github.com> Date: Wed, 17 Jul 2024 10:28:04 -0700 Subject: [PATCH 3/5] increase coverage --- ...est_griptape_cloud_observability_driver.py | 40 +++++++++++++++++-- ...est_open_telemetry_observability_driver.py | 7 ++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py b/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py index 98b5ea043..f559a9077 100644 --- a/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py +++ b/tests/unit/drivers/observability/test_griptape_cloud_observability_driver.py @@ -1,3 +1,6 @@ +import os +from uuid import UUID + import pytest from opentelemetry.sdk.trace import Event, ReadableSpan from opentelemetry.trace import SpanContext, Status, StatusCode @@ -10,9 +13,23 @@ class TestGriptapeCloudObservabilityDriver: @pytest.fixture() def driver(self): - return GriptapeCloudObservabilityDriver( - base_url="http://base-url:1234", api_key="api-key", structure_run_id="structure-run-id" - ) + environ = { + "GT_CLOUD_BASE_URL": "http://base-url:1234", + "GT_CLOUD_API_KEY": "api-key", + "GT_CLOUD_STRUCTURE_RUN_ID": "structure-run-id", + } + original_environ = {} + for key in environ: + original_environ[key] = environ.get(key) + os.environ[key] = environ[key] + + yield GriptapeCloudObservabilityDriver() + + for key, value in original_environ.items(): + if value is None: + del os.environ[key] + else: + os.environ[key] = value @pytest.fixture(autouse=True) def mock_span_exporter_class(self, mocker): @@ -39,6 +56,10 @@ def test_init(self, mock_span_exporter_class, mock_span_exporter): mock_span_exporter.export.assert_not_called() + def test_init_raises_when_structure_run_is_none(self): + with pytest.raises(ValueError, match="structure_run_id must be set"): + GriptapeCloudObservabilityDriver(structure_run_id=None) + def test_context_manager_pass(self, driver, mock_span_exporter): expected_spans = ExpectedSpans(spans=[ExpectedSpan(name="main", parent=None, status_code=StatusCode.OK)]) @@ -145,6 +166,19 @@ def method(self, word: str): mock_span_exporter.export.assert_called_with(expected_spans) mock_span_exporter.export.reset_mock() + def test_get_span_id(self, driver): + assert driver.get_span_id() is None + with driver: + # Span ID's returned from GriptapeCloudObservabilityDriver should be valid UUIDs + assert self._is_valid_uuid(driver.get_span_id()) + + def _is_valid_uuid(self, val: str) -> bool: + try: + UUID(str(val)) + return True + except ValueError: + return False + class TestGriptapeCloudObservabilityDriverSpanExporter: @pytest.fixture() diff --git a/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py b/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py index 44da64727..b903fb1c9 100644 --- a/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py +++ b/tests/unit/drivers/observability/test_open_telemetry_observability_driver.py @@ -188,3 +188,10 @@ def func(word: str): assert mock_span_exporter.export.call_count == 1 mock_span_exporter.export.assert_called_with(expected_spans) mock_span_exporter.export.reset_mock() + + def test_get_span_id(self, driver): + assert driver.get_span_id() is None + with driver: + span_id = driver.get_span_id() + assert span_id is not None + assert isinstance(span_id, str) From 3a133859e03d697716b36adae0b3e7138a9b9b45 Mon Sep 17 00:00:00 2001 From: dylanholmes <4370153+dylanholmes@users.noreply.github.com> Date: Wed, 17 Jul 2024 11:08:12 -0700 Subject: [PATCH 4/5] increase coverage --- .../test_no_op_observability_driver.py | 32 +++++++++++++++++++ .../unit/observability/test_observability.py | 8 +++++ 2 files changed, 40 insertions(+) create mode 100644 tests/unit/drivers/observability/test_no_op_observability_driver.py diff --git a/tests/unit/drivers/observability/test_no_op_observability_driver.py b/tests/unit/drivers/observability/test_no_op_observability_driver.py new file mode 100644 index 000000000..3a8cccc8d --- /dev/null +++ b/tests/unit/drivers/observability/test_no_op_observability_driver.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +from griptape.common.observable import Observable +from griptape.drivers.observability.no_op_observability_driver import NoOpObservabilityDriver + + +class TestNoOpObservabilityDriver: + @pytest.fixture() + def driver(self): + return NoOpObservabilityDriver() + + def test_observe(self, driver): + def func(word: str): + return word + " you" + + class Klass: + def method(self, word: str): + return word + " yous" + + instance = Klass() + + with driver: + assert driver.observe(Observable.Call(func=func, instance=None, args=["Hi"])) == "Hi you" + assert driver.observe(Observable.Call(func=instance.method, instance=instance, args=["Bye"])) == "Bye yous" + + def test_get_span_id(self, driver): + assert driver.get_span_id() is None + + with driver: + assert driver.get_span_id() is None diff --git a/tests/unit/observability/test_observability.py b/tests/unit/observability/test_observability.py index 208a60a93..6ed87e6b5 100644 --- a/tests/unit/observability/test_observability.py +++ b/tests/unit/observability/test_observability.py @@ -40,3 +40,11 @@ def test_context_manager_exception(self, mock_observability_driver): mock_observability_driver.__exit__.assert_called_once_with(*e._excinfo) assert observability._global_observability_driver is None + + def test_nested_context_manager_raises_exception(self, mock_observability_driver): + assert observability._global_observability_driver is None + + with pytest.raises(Exception, match="Observability driver already set."), Observability( + observability_driver=mock_observability_driver + ), Observability(observability_driver=mock_observability_driver): + pass From ef4204eb7f03d4b6696999a4c104821c6b41ef92 Mon Sep 17 00:00:00 2001 From: dylanholmes <4370153+dylanholmes@users.noreply.github.com> Date: Wed, 17 Jul 2024 11:34:50 -0700 Subject: [PATCH 5/5] pr comments --- .../drivers/observability-drivers.md | 7 ++ .../griptape_cloud_observability_driver.py | 66 +++++++++---------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/docs/griptape-framework/drivers/observability-drivers.md b/docs/griptape-framework/drivers/observability-drivers.md index 22ae85a5e..8c2c5d2a5 100644 --- a/docs/griptape-framework/drivers/observability-drivers.md +++ b/docs/griptape-framework/drivers/observability-drivers.md @@ -8,6 +8,9 @@ Observability Drivers are used by [Observability](../structures/observability.md ## Griptape Cloud +!!! info + This driver requires the `drivers-observability-griptape-cloud` [extra](../index.md#extras). + The Griptape Cloud Observability Driver instruments `@observable` functions and methods with metrics and traces for use with the Griptape Cloud. !!! note @@ -33,8 +36,12 @@ with Observability(observability_driver=observability_driver): ## OpenTelemetry +!!! info + This driver requires the `drivers-observability-opentelemetry` [extra](../index.md#extras). + The [OpenTelemetry](https://opentelemetry.io/) Observability Driver instruments `@observable` functions and methods with metrics and traces for use with OpenTelemetry. You must configure a destination for the telemetry by providing a `SpanProcessor` to the Driver. + Here is an example of how to use the `OpenTelemetryObservabilityDriver` with the `Observability` context manager to output the telemetry directly to the console: ```python title="PYTEST_IGNORE" diff --git a/griptape/drivers/observability/griptape_cloud_observability_driver.py b/griptape/drivers/observability/griptape_cloud_observability_driver.py index c6df9cd35..e520def50 100644 --- a/griptape/drivers/observability/griptape_cloud_observability_driver.py +++ b/griptape/drivers/observability/griptape_cloud_observability_driver.py @@ -22,39 +22,6 @@ @define class GriptapeCloudObservabilityDriver(OpenTelemetryObservabilityDriver): - service_name: str = field(default="griptape-cloud", kw_only=True) - base_url: str = field( - default=Factory(lambda: os.getenv("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")), kw_only=True - ) - api_key: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_API_KEY")), kw_only=True) - headers: dict = field( - default=Factory(lambda self: {"Authorization": f"Bearer {self.api_key}"}, takes_self=True), kw_only=True - ) - structure_run_id: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_STRUCTURE_RUN_ID")), kw_only=True) - span_processor: SpanProcessor = field( - default=Factory( - lambda self: BatchSpanProcessor( - GriptapeCloudObservabilityDriver.SpanExporter( - base_url=self.base_url, - api_key=self.api_key, - headers=self.headers, - structure_run_id=self.structure_run_id, - ) - ), - takes_self=True, - ), - kw_only=True, - ) - trace_provider: TracerProvider = field(default=Factory(lambda: TracerProvider()), kw_only=True) - - @staticmethod - def format_trace_id(trace_id: int) -> str: - return str(UUID(int=trace_id)) - - @staticmethod - def format_span_id(span_id: int) -> str: - return str(UUID(int=span_id)) - @define class SpanExporter(SpanExporter): base_url: str = field(kw_only=True) @@ -90,6 +57,31 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: response = requests.post(url=url, json=payload, headers=self.headers) return SpanExportResult.SUCCESS if response.status_code == 200 else SpanExportResult.FAILURE + service_name: str = field(default="griptape-cloud", kw_only=True) + base_url: str = field( + default=Factory(lambda: os.getenv("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")), kw_only=True + ) + api_key: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_API_KEY")), kw_only=True) + headers: dict = field( + default=Factory(lambda self: {"Authorization": f"Bearer {self.api_key}"}, takes_self=True), kw_only=True + ) + structure_run_id: str = field(default=Factory(lambda: os.getenv("GT_CLOUD_STRUCTURE_RUN_ID")), kw_only=True) + span_processor: SpanProcessor = field( + default=Factory( + lambda self: BatchSpanProcessor( + GriptapeCloudObservabilityDriver.SpanExporter( + base_url=self.base_url, + api_key=self.api_key, + headers=self.headers, + structure_run_id=self.structure_run_id, + ) + ), + takes_self=True, + ), + kw_only=True, + ) + trace_provider: TracerProvider = field(default=Factory(lambda: TracerProvider()), kw_only=True) + @structure_run_id.validator # pyright: ignore[reportAttributeAccessIssue] def validate_run_id(self, _: Attribute, structure_run_id: str) -> None: if structure_run_id is None: @@ -97,6 +89,14 @@ def validate_run_id(self, _: Attribute, structure_run_id: str) -> None: "structure_run_id must be set either in the constructor or as an environment variable (GT_CLOUD_STRUCTURE_RUN_ID)." ) + @staticmethod + def format_trace_id(trace_id: int) -> str: + return str(UUID(int=trace_id)) + + @staticmethod + def format_span_id(span_id: int) -> str: + return str(UUID(int=span_id)) + def get_span_id(self) -> Optional[str]: span = get_current_span() if span is INVALID_SPAN: