Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ USER app
ENV PATH="/app/.venv/bin:$PATH"
ENV AGENTEVALS_SERVER_URL=http://127.0.0.1:8001

EXPOSE 8001 4318 8080
EXPOSE 8001 4318 4317 8080

CMD ["agentevals", "serve", "--host", "0.0.0.0", "--port", "8001", "--otlp-port", "4318", "--mcp-port", "8080"]
CMD ["agentevals", "serve", "--host", "0.0.0.0", "--port", "8001", "--otlp-http-port", "4318", "--otlp-grpc-port", "4317", "--mcp-port", "8080"]
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,14 @@ export OTEL_RESOURCE_ATTRIBUTES="agentevals.session_name=my-agent"
python your_agent.py
```

Traces stream to the UI in real-time. Works with LangChain, Strands, Google ADK, OpenAI Agents SDK, or any framework that emits OTel spans (`http/protobuf` and `http/json` supported). Sessions are auto-created and grouped by `agentevals.session_name`. Set `agentevals.eval_set_id` to associate traces with an eval set.
For OTLP/gRPC exporters, use:

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
```

Traces stream to the UI in real-time. Works with LangChain, Strands, Google ADK, OpenAI Agents SDK, or any framework that emits OTel spans (`http/protobuf`, `http/json`, and OTLP/gRPC supported). Sessions are auto-created and grouped by `agentevals.session_name`. Set `agentevals.eval_set_id` to associate traces with an eval set.

See [examples/zero-code-examples/](examples/zero-code-examples/) for working examples.

Expand Down
2 changes: 1 addition & 1 deletion charts/agentevals/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
apiVersion: v2
name: agentevals
description: agentevals web UI, OTLP HTTP receiver, and MCP (Streamable HTTP)
description: agentevals web UI, OTLP HTTP+gRPC receivers, and MCP (Streamable HTTP)
type: application
version: 0.1.0
appVersion: "0.5.2"
5 changes: 3 additions & 2 deletions charts/agentevals/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
1. UI and API are available at port {{ .Values.service.http.port }} (Service port name: http).
2. OTLP HTTP receiver: port {{ .Values.service.otlpHttp.port }} (OTEL_EXPORTER_OTLP_ENDPOINT=http://<service>:{{ .Values.service.otlpHttp.port }}).
3. MCP (Streamable HTTP): port {{ .Values.service.mcp.port }}, path /mcp (e.g. http://<service>:{{ .Values.service.mcp.port }}/mcp).
3. OTLP gRPC receiver: port {{ .Values.service.otlpGrpc.port }} (OTEL_EXPORTER_OTLP_ENDPOINT=<service>:{{ .Values.service.otlpGrpc.port }}, OTEL_EXPORTER_OTLP_PROTOCOL=grpc).
4. MCP (Streamable HTTP): port {{ .Values.service.mcp.port }}, path /mcp (e.g. http://<service>:{{ .Values.service.mcp.port }}/mcp).
{{- if .Values.ephemeralVolume.enabled }}
4. An emptyDir is mounted at /tmp with HOME=/tmp/agentevals-home (ephemeral; lost on pod restart). Set ephemeralVolume.enabled=false and readOnlyRootFilesystem=false if you need a writable root without this mount.
5. An emptyDir is mounted at /tmp with HOME=/tmp/agentevals-home (ephemeral; lost on pod restart). Set ephemeralVolume.enabled=false and readOnlyRootFilesystem=false if you need a writable root without this mount.
{{- end }}

Get the Service URL:
Expand Down
3 changes: 3 additions & 0 deletions charts/agentevals/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ spec:
- name: otlp-http
containerPort: {{ .Values.service.otlpHttp.containerPort }}
protocol: TCP
- name: otlp-grpc
containerPort: {{ .Values.service.otlpGrpc.containerPort }}
protocol: TCP
- name: mcp
containerPort: {{ .Values.service.mcp.containerPort }}
protocol: TCP
Expand Down
4 changes: 4 additions & 0 deletions charts/agentevals/templates/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ spec:
port: {{ .Values.service.otlpHttp.port }}
targetPort: otlp-http
protocol: TCP
- name: otlp-grpc
port: {{ .Values.service.otlpGrpc.port }}
targetPort: otlp-grpc
protocol: TCP
- name: mcp
port: {{ .Values.service.mcp.port }}
targetPort: mcp
Expand Down
4 changes: 4 additions & 0 deletions charts/agentevals/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ service:
otlpHttp:
port: 4318
containerPort: 4318
# -- OTLP gRPC receiver port
otlpGrpc:
port: 4317
containerPort: 4317
# -- MCP (Streamable HTTP) port
mcp:
port: 8080
Expand Down
11 changes: 9 additions & 2 deletions docs/otel-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,18 @@ If you maintain an OTel-instrumented agent framework and want to align with the

## OTLP Receiver

agentevals runs an OTLP HTTP receiver on port 4318 (the standard OTLP HTTP port) that accepts:
agentevals runs:

- OTLP HTTP receiver on port 4318 (standard OTLP HTTP port)
- OTLP gRPC receiver on port 4317 (standard OTLP gRPC port).

OTLP HTTP accepts:

| Endpoint | Content Types |
|----------|--------------|
| `/v1/traces` | `application/json`, `application/x-protobuf` |
| `/v1/logs` | `application/json`, `application/x-protobuf` |

Point your standard OTel exporters at `http://localhost:4318` and traces will stream into agentevals automatically. See [examples/README.md](../examples/README.md) for zero-code setup instructions.
Point OTLP/HTTP exporters at `http://localhost:4318`.
Point OTLP/gRPC exporters at `localhost:4317` with `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`.
Traces and logs stream into agentevals automatically. See [examples/README.md](../examples/README.md) for zero-code setup instructions.
11 changes: 9 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ export OTEL_RESOURCE_ATTRIBUTES="agentevals.session_name=my-agent,agentevals.eva
python your_agent.py
```

The OTLP receiver runs on port 4318 (standard OTLP HTTP port) and accepts both `http/protobuf` and `http/json`. Sessions are auto-created from incoming traces and grouped by `agentevals.session_name`.
For OTLP/gRPC exporters, use:

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
```

agentevals accepts OTLP/HTTP on port 4318 (`http/protobuf` and `http/json`) and OTLP/gRPC on port 4317. Sessions are auto-created from incoming traces and grouped by `agentevals.session_name`.

| Example | Framework | LLM Provider |
|---------|-----------|-------------|
Expand Down Expand Up @@ -108,7 +115,7 @@ The zero-code and SDK examples implement the same toy agent (dice rolling + prim

| Example | Description |
|---------|-------------|
| [kubernetes/](./kubernetes/) | Deploy agentevals with kagent on Kubernetes, using an OTel Collector as a gRPC to HTTP bridge. Includes a walkthrough for comparing two kagent agents (different models) and evaluating them with tool trajectory and response match scores. |
| [kubernetes/](./kubernetes/) | Deploy agentevals with kagent on Kubernetes using native OTLP gRPC ingestion (or optionally an OTel Collector). Includes a walkthrough for comparing two kagent agents (different models) and evaluating them with tool trajectory and response match scores. |

## Advanced: GenAI Semantic Convention Patterns

Expand Down
33 changes: 22 additions & 11 deletions examples/kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
Run agentevals alongside [kagent](https://github.com/kagent-dev/kagent) on Kubernetes to evaluate AI agent conversations in real time. This example deploys three components:

1. **agentevals** receives OTLP traces over HTTP and serves the evaluation UI
2. **OTel Collector** bridges the protocol gap: kagent exports traces via gRPC, but agentevals only supports OTLP/HTTP today, so the Collector converts gRPC to HTTP
2. **OTel Collector** Optional, useful when you want centralized telemetry
controls.
3. **kagent** provides Kubernetes-native AI agents with built-in OTel instrumentation (gRPC export only)

```
kagent (gRPC :4317) --> OTel Collector --> agentevals (HTTP :4318)
|
UI on :8001
kagent (gRPC :4317) --> OTel Collector( optional ) --> agentevals (gRPC :4317 / HTTP :4318)
|
UI on :8001
```

## Prerequisites
Expand All @@ -33,12 +34,20 @@ This creates a single pod exposing:
| Port | Purpose |
|------|---------|
| 8001 | Web UI and API |
| 4317 | OTLP gRPC receiver (traces and logs) |
| 4318 | OTLP HTTP receiver (traces and logs) |
| 8080 | MCP (Streamable HTTP) |

### 2. OTel Collector (gRPC to HTTP bridge)
### 2. OTel Collector (optional)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please update the docs, so it's clear when to use what port and pipeline configs? We have a couple of leftovers from the previous iterations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried improving the docs.

What do you want me to make more clear? Thanks :)


kagent exports traces over gRPC (port 4317), but agentevals accepts OTLP over HTTP (port 4318). The OTel Collector bridges the two protocols.
Native gRPC ingestion in agentevals is sufficient for most setups, but an
intermediate collector is still useful when you want centralized telemetry
controls:

- traffic shaping (batching, retries, backpressure)
- filtering or redaction before data reaches agentevals
- routing/fan-out to additional backends
- protocol translation for mixed clients

```bash
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
Expand All @@ -51,15 +60,15 @@ helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
--set image.repository=otel/opentelemetry-collector \
--set ports.otlp.enabled=true \
--set ports.otlp-http.enabled=false \
--set config.exporters.otlphttp.endpoint="http://agentevals.default.svc.cluster.local:4318" \
--set config.exporters.otlphttp.compression="none" \
--set config.exporters.otlp.endpoint="agentevals.default.svc.cluster.local:4317" \
--set config.exporters.otlp.compression="gzip" \
--set config.service.pipelines.traces.receivers[0]=otlp \
--set config.service.pipelines.traces.exporters[0]=otlphttp \
--set config.service.pipelines.traces.exporters[0]=otlp \
--set config.service.pipelines.logs.receivers[0]=otlp \
--set config.service.pipelines.logs.exporters[0]=otlphttp
--set config.service.pipelines.logs.exporters[0]=otlp
```

> **Note:** If you deployed agentevals in a namespace other than `default`, update the `endpoint` value accordingly: `http://agentevals.<namespace>.svc.cluster.local:4318`.
> **Note:** If you deployed agentevals in a namespace other than `default`, update the `endpoint` value accordingly: `http://agentevals.<namespace>.svc.cluster.local:4317`.

### 3. kagent

Expand Down Expand Up @@ -89,6 +98,8 @@ helm upgrade --install kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent \

This installs kagent with only the default Helm agent (`helm-agent`) and the K8s troubleshooter enabled, and points its OTel exporter at the Collector.

> **Note:** If you are not running an OTel Collector, point `otel.tracing.exporter.otlp.endpoint` directly to the agentevals OTLP gRPC endpoint instead: `agentevals.default.svc.cluster.local:4317`.

### Verify the deployment

```bash
Expand Down
17 changes: 15 additions & 2 deletions src/agentevals/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from fastapi import HTTPException, Request

Expand All @@ -15,9 +15,22 @@ def get_trace_manager(request: Request) -> StreamingTraceManager | None:
return getattr(request.app.state, "trace_manager", None)


def get_trace_manager_from_app(app: Any) -> StreamingTraceManager | None:
"""Return the StreamingTraceManager from an app object or None."""
return getattr(app.state, "trace_manager", None)


def require_trace_manager(request: Request) -> StreamingTraceManager:
"""Return the StreamingTraceManager, raising 503 if live mode is off."""
mgr = getattr(request.app.state, "trace_manager", None)
mgr = get_trace_manager_from_app(request.app)
if mgr is None:
raise HTTPException(status_code=503, detail="Live mode not enabled")
return mgr


def require_trace_manager_from_app(app: Any) -> StreamingTraceManager:
"""Return the StreamingTraceManager from app, raising RuntimeError if missing."""
mgr = get_trace_manager_from_app(app)
if mgr is None:
raise RuntimeError("Live mode not enabled")
return mgr
98 changes: 98 additions & 0 deletions src/agentevals/api/otlp_grpc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""OTLP gRPC receiver services for traces and logs.

Receives standard OTLP/gRPC Export requests on port 4317 and forwards them
into the same StreamingTraceManager pipeline used by OTLP/HTTP routes.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from google.protobuf.json_format import MessageToDict
from opentelemetry.proto.collector.logs.v1 import logs_service_pb2, logs_service_pb2_grpc
from opentelemetry.proto.collector.trace.v1 import trace_service_pb2, trace_service_pb2_grpc

from .otlp_processing import fix_protobuf_id_fields, process_logs, process_traces

if TYPE_CHECKING:
from grpc import aio

from ..streaming.ws_server import StreamingTraceManager

logger = logging.getLogger(__name__)

GRPC_SHUTDOWN_GRACE_SECONDS = 5
DEFAULT_GRPC_MAX_CONCURRENT_RPCS = 32
DEFAULT_GRPC_MAX_MESSAGE_BYTES = 8 * 1024 * 1024


class OtlpTraceService(trace_service_pb2_grpc.TraceServiceServicer):
"""OTLP TraceService gRPC implementation."""

def __init__(self, manager: StreamingTraceManager):
self._manager = manager

async def Export(self, request, context): # noqa: N802 (gRPC method name)
body = MessageToDict(request, preserving_proto_field_name=False)
fix_protobuf_id_fields(body)
await process_traces(body, self._manager)
return trace_service_pb2.ExportTraceServiceResponse()


class OtlpLogsService(logs_service_pb2_grpc.LogsServiceServicer):
"""OTLP LogsService gRPC implementation."""

def __init__(self, manager: StreamingTraceManager):
self._manager = manager

async def Export(self, request, context): # noqa: N802 (gRPC method name)
body = MessageToDict(request, preserving_proto_field_name=False)
fix_protobuf_id_fields(body)
await process_logs(body, self._manager)
return logs_service_pb2.ExportLogsServiceResponse()


def create_otlp_grpc_server(
host: str,
port: int,
manager: StreamingTraceManager,
*,
max_concurrent_rpcs: int = DEFAULT_GRPC_MAX_CONCURRENT_RPCS,
max_message_bytes: int = DEFAULT_GRPC_MAX_MESSAGE_BYTES,
) -> aio.Server:
"""Create an OTLP gRPC server bound to host:port."""
try:
import grpc
except ImportError as exc: # pragma: no cover - environment-dependent
raise RuntimeError("OTLP gRPC receiver requires grpcio. Install with: pip install grpcio") from exc

server = grpc.aio.server(
compression=grpc.Compression.Gzip,
maximum_concurrent_rpcs=max_concurrent_rpcs,
options=[
("grpc.max_receive_message_length", max_message_bytes),
("grpc.max_send_message_length", max_message_bytes),
],
)
trace_service_pb2_grpc.add_TraceServiceServicer_to_server(OtlpTraceService(manager), server)
logs_service_pb2_grpc.add_LogsServiceServicer_to_server(OtlpLogsService(manager), server)

listen_addr = f"{host}:{port}"
bound_port = server.add_insecure_port(listen_addr)
if bound_port == 0:
raise RuntimeError(f"Failed to bind OTLP gRPC receiver to {listen_addr}")

logger.info(
"OTLP gRPC receiver configured at %s (gzip enabled, max_concurrent_rpcs=%d, max_msg=%d)",
listen_addr,
max_concurrent_rpcs,
max_message_bytes,
)
return server


async def stop_otlp_grpc_server(server: aio.Server, *, force: bool = False) -> None:
"""Stop the OTLP gRPC server with graceful or forced semantics."""
grace = 0 if force else GRPC_SHUTDOWN_GRACE_SECONDS
await server.stop(grace=grace)
Loading