Fix/81 dapr http endpoint (#107)

* Fix: #81 by implementing custom tool for wrapping HTTP calling

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Ensure _base_url is passed if FQDN

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Ruff

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Explicit exclude the other tools from type checking so we can check http tool

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct arg-type for url

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Ruff formatting

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Update deps

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: First draft impl. of OTel

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: F811

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: import

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct import and pass var

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Instantiation instead of model

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Circular ref

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Remove specific tracer as lib should pick it up & add logger

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Attempt to aquire tracer and use logger

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Set the logger

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Allow passing custom endpoint per provider

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Set tmp correct logger port

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct sending json encoded

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Ensure requests client always run http

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct tmp port for logger

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: Ensure /v1/[traces|metrics|logs] always in otlp_endpoint

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Don't capitalize

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Ensure we check for v1 and set if not

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Ensure http always

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Transition otel to tools.utils

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct import

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: Clean init of provider with validator func

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: conform to new validator

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Remove not needed endpoint pass to logger client

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: F821

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: F541

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Remove redundant check

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Improve validator func for less code

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Remove unused import

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: Refine checks and ensure resiliency on url creation

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Add parsing of DAPR_AGENTS_OTEL_ENABLED to disable OTel from client

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Ruff

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Convert to reusable function passing the http verb

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct string to upper rather than capitalize

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Bump python version on build agent as 3.9 don't contain switch statement

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Pass version as str

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Move otel into dapr_agents.agent.telemetry

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Include errors from dapr_agents.agent.telemetry

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct import of DaprAgentsOTel

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Include type-check on http

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Proper name of DaprAgentsOTel

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Correct imports

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Raising ToolError results in workflow breaking rather than reiterating the tool request

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Ruff

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: .json() not guaranteed to hold a value

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Remove unused import

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Decorate the tool

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: Better naming convention for docstring

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Feat: Export tool decoration for consumption

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Fix: Revert

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

* Chore: Import & formatting

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>

---------

Signed-off-by: Casper Guldbech Nielsen <scni@novonordisk.com>
Co-authored-by: Yaron Schneider <schneider.yaron@live.com>
This commit is contained in:
Casper Nielsen 2025-05-02 18:04:29 +02:00 committed by GitHub
parent 889b7bf7ef
commit e27f5befb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 372 additions and 5 deletions

View File

@ -20,10 +20,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: 3.9
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@ -0,0 +1 @@
from .otel import DaprAgentsOTel

View File

@ -0,0 +1,144 @@
from logging import Logger
from typing import Union
from opentelemetry._logs import set_logger_provider
from opentelemetry.metrics import set_meter_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import set_tracer_provider
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
class DaprAgentsOTel:
"""
OpenTelemetry configuration for Dapr agents.
"""
def __init__(self, service_name: str = "", otlp_endpoint: str = ""):
# Configure OpenTelemetry
self.service_name = service_name
self.otlp_endpoint = otlp_endpoint
self.setup_resources()
def setup_resources(self):
"""
Set up the resource for OpenTelemetry.
"""
self._resource = Resource.create(
attributes={
SERVICE_NAME: str(self.service_name),
}
)
def create_and_instrument_meter_provider(
self,
otlp_endpoint: str = "",
) -> MeterProvider:
"""
Returns a `MeterProvider` that is configured to export metrics using the `PeriodicExportingMetricReader`
which means that metrics are exported periodically in the background. The interval can be set by
the environment variable `OTEL_METRIC_EXPORT_INTERVAL`. The default value is 60000ms (1 minute).
Also sets the global OpenTelemetry meter provider to the returned meter provider.
"""
# Ensure the endpoint is set correctly
endpoint = self._endpoint_validator(
endpoint=self.otlp_endpoint if otlp_endpoint == "" else otlp_endpoint,
telemetry_type="metrics",
)
metric_exporter = OTLPMetricExporter(endpoint=str(endpoint))
metric_reader = PeriodicExportingMetricReader(metric_exporter)
meter_provider = MeterProvider(
resource=self._resource, metric_readers=[metric_reader]
)
set_meter_provider(meter_provider)
return meter_provider
def create_and_instrument_tracer_provider(
self,
otlp_endpoint: str = "",
) -> TracerProvider:
"""
Returns a `TracerProvider` that is configured to export traces using the `BatchSpanProcessor`
which means that traces are exported in batches. The batch size can be set by
the environment variable `OTEL_TRACES_EXPORT_BATCH_SIZE`. The default value is 512.
Also sets the global OpenTelemetry tracer provider to the returned tracer provider.
"""
# Ensure the endpoint is set correctly
endpoint = self._endpoint_validator(
endpoint=self.otlp_endpoint if otlp_endpoint == "" else otlp_endpoint,
telemetry_type="traces",
)
trace_exporter = OTLPSpanExporter(endpoint=str(endpoint))
tracer_processor = BatchSpanProcessor(trace_exporter)
tracer_provider = TracerProvider(resource=self._resource)
tracer_provider.add_span_processor(tracer_processor)
set_tracer_provider(tracer_provider)
return tracer_provider
def create_and_instrument_logging_provider(
self,
logger: Logger,
otlp_endpoint: str = "",
) -> LoggerProvider:
"""
Returns a `LoggingProvider` that is configured to export logs using the `BatchLogProcessor`
which means that logs are exported in batches. The batch size can be set by
the environment variable `OTEL_LOGS_EXPORT_BATCH_SIZE`. The default value is 512.
Also sets the global OpenTelemetry logging provider to the returned logging provider.
"""
# Ensure the endpoint is set correctly
endpoint = self._endpoint_validator(
endpoint=self.otlp_endpoint if otlp_endpoint == "" else otlp_endpoint,
telemetry_type="logs",
)
log_exporter = OTLPLogExporter(endpoint=str(endpoint))
logging_provider = LoggerProvider(resource=self._resource)
logging_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
set_logger_provider(logging_provider)
handler = LoggingHandler(logger_provider=logging_provider)
logger.addHandler(handler)
return logging_provider
def _endpoint_validator(
self,
endpoint: str,
telemetry_type: str,
) -> Union[str | Exception]:
"""
Validates the endpoint and method.
"""
if endpoint == "":
raise ValueError(
"OTLP endpoint must be set either in the environment variable OTEL_EXPORTER_OTLP_ENDPOINT or in the constructor."
)
if endpoint.startswith("https://"):
raise NotImplementedError(
"OTLP over HTTPS is not supported. Please use HTTP."
)
endpoint = (
endpoint
if endpoint.endswith(f"/v1/{telemetry_type}")
else f"{endpoint}/v1/{telemetry_type}"
)
endpoint = endpoint if endpoint.startswith("http://") else f"http://{endpoint}"
return endpoint

View File

@ -0,0 +1 @@
from .client import DaprHTTPClient

View File

@ -0,0 +1,197 @@
import os
from typing import Dict, Optional, Any, Union
from distutils.util import strtobool
import logging
import requests
from pydantic import BaseModel, Field, PrivateAttr
from dapr_agents.types import ToolError
from dapr_agents import tool
from urllib.parse import urlparse
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
logger = logging.getLogger(__name__)
class DaprHTTPClient(BaseModel):
"""
Client for sending HTTP requests to Dapr endpoints.
"""
dapr_app_id: Optional[str] = Field(
default="", description="Optional name of the Dapr App ID to invoke."
)
dapr_http_endpoint: Optional[str] = Field(
default="",
description="Optional name of the HTTPEndpoint to call for invocation",
)
http_endpoint: Optional[str] = Field(
default="", description="Optional FQDN URL to request to."
)
path: Optional[str] = Field(
default="", description="Optional name of the path to invoke."
)
headers: Optional[Dict[str, str]] = Field(
default={},
description="Default headers to include in all requests.",
)
# Private attributes not exposed in model schema
_base_url: str = PrivateAttr(default="http://localhost:3500/v1.0/invoke")
def model_post_init(self, __context: Any) -> None:
"""Initialize the client after the model is created."""
try:
otel_enabled: bool = bool(
strtobool(os.getenv("DAPR_AGENTS_OTEL_ENABLED", "True"))
)
except ValueError:
otel_enabled = False
if otel_enabled:
from dapr_agents.agent.telemetry.otel import DaprAgentsOTel # type: ignore[import-not-found]
otel_client = DaprAgentsOTel(
service_name=os.getenv("OTEL_SERVICE_NAME", "dapr-http-client"),
otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
)
tracer = otel_client.create_and_instrument_tracer_provider()
trace.set_tracer_provider(tracer)
otel_logger = otel_client.create_and_instrument_logging_provider(
logger=logger,
)
set_logger_provider(otel_logger)
RequestsInstrumentor().instrument()
logger.debug("Initializing DaprHTTPClient client")
super().model_post_init(__context)
def do_http_request(
self,
payload: dict[str, str],
endpoint: str = "",
path: str = "",
verb: str = "GET",
) -> Union[tuple[int, str] | ToolError]:
"""
Send a POST request to the specified endpoint with the given input.
Args:
endpoint_url (str): The host of the URI to send the request to.
payload (dict[str, str]): The payload to include in the request.
path (str): The path of the URI to invoke including any query strings appended.
verb (str): The HTTP verb. Either GET or POST.
Returns:
A tuple with the http status code and respose or a ToolError.
"""
try:
url = self._validate_endpoint_type(
endpoint=endpoint, path=self.path if path == "" else path
)
except ToolError as e:
logger.error(f"Error validating endpoint: {e}")
raise e
logger.debug(
f"[HTTP] Sending POST request to '{url}' with input '{payload}' and headers '{self.headers}"
)
match verb.upper():
case "GET":
response = requests.get(url=str(url), headers=self.headers)
case "POST":
response = requests.post(
url=str(url), headers=self.headers, json=payload
)
case _:
raise ValueError(
f"Value for 'verb' not in expected format ['GET'|'POST']: {verb}"
)
logger.debug(
f"Request returned status code '{response.status_code}' and '{response.text}'"
)
if not response.ok:
raise ToolError(
f"Error occured sending the request. Received '{response.status_code}' - '{response.text}'"
)
return (response.status_code, response.text)
def _validate_endpoint_type(
self, endpoint: str, path: Optional[str | None]
) -> Union[str | ToolError]:
if path == "":
raise ToolError("No path provided. Please provide a valid path.")
if isinstance(path, str) and path.startswith("/"):
# Remove leading slash
path = path[1:]
try:
if self.dapr_app_id != "":
# Prefered option
if isinstance(self.dapr_app_id, str) and self.dapr_app_id.endswith("/"):
# Remove trailing slash
self.dapr_app_id = self.dapr_app_id[:-1]
url = f"{self._base_url}/{self.dapr_app_id}/method/{self.path if path == '' else path}"
elif self.dapr_http_endpoint != "":
# Dapr HTTPEndpoint
if isinstance(
self.dapr_http_endpoint, str
) and self.dapr_http_endpoint.endswith("/"):
# Remove trailing slash
self.dapr_http_endpoint = self.dapr_http_endpoint[:-1]
url = f"{self._base_url}/{self.dapr_http_endpoint}/method/{self.path if path == '' else path}"
elif self.http_endpoint != "":
# FQDN URL
if isinstance(self.http_endpoint, str) and self.http_endpoint.endswith(
"/"
):
# Remove trailing slash
self.http_endpoint = self.http_endpoint[:-1]
url = f"{self._base_url}/{self.http_endpoint}/method/{self.path if path == '' else path}"
elif endpoint != "":
# Fallback to default
if isinstance(endpoint, str) and endpoint.endswith("/"):
# Remove trailing slash
endpoint = endpoint[:-1]
url = f"{self._base_url}/{endpoint}/method/{self.path if path == '' else path}"
else:
raise ToolError(
"No endpoint provided. Please provide a valid dapr-app-id, HTTPEndpoint or endpoint."
)
except Exception as e:
logger.error(f"Error validating endpoint: {e}")
raise ToolError(
"Error occured while validating the endpoint. Please check the provided values."
)
if not self._validate_url(url):
raise ToolError(f"'{url}' is not a valid URL.")
return url
def _validate_url(self, url) -> bool:
"""
Valides URL for HTTP requests
"""
logger.debug(f"[HTTP] Url to be validated: {url}")
try:
parsed_url = urlparse(url=url)
return all([parsed_url.scheme, parsed_url.netloc])
except AttributeError:
return False

View File

@ -14,4 +14,7 @@ pyyaml==6.0.2
rich==13.9.4
huggingface_hub==0.27.1
numpy==2.2.2
mypy==1.15.0
mypy==1.15.0
opentelemetry-distro==0.53b1
opentelemetry-exporter-otlp==1.32.1
opentelemetry-instrumentation-requests==0.53b1

View File

@ -16,6 +16,9 @@ exclude =
[mypy-dapr_agents.agent.*]
ignore_errors = True
[mypy-dapr_agents.agent.telemetry.*]
ignore_errors = False
[mypy-dapr_agents.document.*]
ignore_errors = True
@ -37,9 +40,21 @@ ignore_errors = True
[mypy-dapr_agents.storage.*]
ignore_errors = True
[mypy-dapr_agents.tool.*]
[mypy-dapr_agents.tool.mcp.*]
ignore_errors = True
[mypy-dapr_agents.tool.utils.*]
ignore_errors = True
[mypy-dapr_agents.tool.openapi.*]
ignore_errors = True
[mypy-dapr_agents.tool.base.*]
ignore_errors = True
[mypy-dapr_agents.tool.http.*]
ignore_errors = False
[mypy-dapr_agents.types.*]
ignore_errors = True

View File

@ -43,6 +43,9 @@ dependencies = [
"rich == 13.9.4",
"huggingface_hub == 0.30.2",
"numpy == 2.2.2",
"opentelemetry-distro == 0.53b1",
"opentelemetry-exporter-otlp == 1.32.1",
"opentelemetry-instrumentation-requests == 0.53b1",
]
[project.urls]

View File

@ -14,4 +14,7 @@ pyyaml==6.0.2
rich==13.9.4
huggingface_hub==0.30.2
numpy==2.2.2
mcp==1.6.0
mcp==1.6.0
opentelemetry-distro==0.53b1
opentelemetry-exporter-otlp==1.32.1
opentelemetry-instrumentation-requests==0.53b1