Skip to content

Commit f8e7172

Browse files
committed
Contract Test
1 parent f54609e commit f8e7172

File tree

8 files changed

+261
-58
lines changed

8 files changed

+261
-58
lines changed

aws-opentelemetry-distro/pyproject.toml

Lines changed: 56 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dynamic = ["version"]
88
description = "AWS OpenTelemetry Python Distro"
99
readme = "README.rst"
1010
license = "Apache-2.0"
11-
requires-python = ">=3.9"
11+
requires-python = ">=3.8"
1212
authors = [
1313
{ name = "Amazon Web Services" },
1414
]
@@ -18,70 +18,68 @@ classifiers = [
1818
"License :: OSI Approved :: Apache Software License",
1919
"Programming Language :: Python",
2020
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.8",
2122
"Programming Language :: Python :: 3.9",
2223
"Programming Language :: Python :: 3.10",
2324
"Programming Language :: Python :: 3.11",
24-
"Programming Language :: Python :: 3.12",
25-
"Programming Language :: Python :: 3.13",
2625
]
2726

2827
dependencies = [
29-
"opentelemetry-api == 1.33.1",
30-
"opentelemetry-sdk == 1.33.1",
31-
"opentelemetry-exporter-otlp-proto-grpc == 1.33.1",
32-
"opentelemetry-exporter-otlp-proto-http == 1.33.1",
33-
"opentelemetry-propagator-b3 == 1.33.1",
34-
"opentelemetry-propagator-jaeger == 1.33.1",
35-
"opentelemetry-exporter-otlp-proto-common == 1.33.1",
28+
"opentelemetry-api == 1.27.0",
29+
"opentelemetry-sdk == 1.27.0",
30+
"opentelemetry-exporter-otlp-proto-grpc == 1.27.0",
31+
"opentelemetry-exporter-otlp-proto-http == 1.27.0",
32+
"opentelemetry-propagator-b3 == 1.27.0",
33+
"opentelemetry-propagator-jaeger == 1.27.0",
34+
"opentelemetry-exporter-otlp-proto-common == 1.27.0",
3635
"opentelemetry-sdk-extension-aws == 2.0.2",
3736
"opentelemetry-propagator-aws-xray == 1.0.1",
38-
"opentelemetry-distro == 0.54b1",
39-
"opentelemetry-processor-baggage == 0.54b1",
40-
"opentelemetry-propagator-ot-trace == 0.54b1",
41-
"opentelemetry-instrumentation == 0.54b1",
42-
"opentelemetry-instrumentation-aws-lambda == 0.54b1",
43-
"opentelemetry-instrumentation-aio-pika == 0.54b1",
44-
"opentelemetry-instrumentation-aiohttp-client == 0.54b1",
45-
"opentelemetry-instrumentation-aiopg == 0.54b1",
46-
"opentelemetry-instrumentation-asgi == 0.54b1",
47-
"opentelemetry-instrumentation-asyncpg == 0.54b1",
48-
"opentelemetry-instrumentation-boto == 0.54b1",
49-
"opentelemetry-instrumentation-boto3sqs == 0.54b1",
50-
"opentelemetry-instrumentation-botocore == 0.54b1",
51-
"opentelemetry-instrumentation-celery == 0.54b1",
52-
"opentelemetry-instrumentation-confluent-kafka == 0.54b1",
53-
"opentelemetry-instrumentation-dbapi == 0.54b1",
54-
"opentelemetry-instrumentation-django == 0.54b1",
55-
"opentelemetry-instrumentation-elasticsearch == 0.54b1",
56-
"opentelemetry-instrumentation-falcon == 0.54b1",
57-
"opentelemetry-instrumentation-fastapi == 0.54b1",
58-
"opentelemetry-instrumentation-flask == 0.54b1",
59-
"opentelemetry-instrumentation-grpc == 0.54b1",
60-
"opentelemetry-instrumentation-httpx == 0.54b1",
61-
"opentelemetry-instrumentation-jinja2 == 0.54b1",
62-
"opentelemetry-instrumentation-kafka-python == 0.54b1",
63-
"opentelemetry-instrumentation-logging == 0.54b1",
64-
"opentelemetry-instrumentation-mysql == 0.54b1",
65-
"opentelemetry-instrumentation-mysqlclient == 0.54b1",
66-
"opentelemetry-instrumentation-pika == 0.54b1",
67-
"opentelemetry-instrumentation-psycopg2 == 0.54b1",
68-
"opentelemetry-instrumentation-pymemcache == 0.54b1",
69-
"opentelemetry-instrumentation-pymongo == 0.54b1",
70-
"opentelemetry-instrumentation-pymysql == 0.54b1",
71-
"opentelemetry-instrumentation-pyramid == 0.54b1",
72-
"opentelemetry-instrumentation-redis == 0.54b1",
73-
"opentelemetry-instrumentation-remoulade == 0.54b1",
74-
"opentelemetry-instrumentation-requests == 0.54b1",
75-
"opentelemetry-instrumentation-sqlalchemy == 0.54b1",
76-
"opentelemetry-instrumentation-sqlite3 == 0.54b1",
77-
"opentelemetry-instrumentation-starlette == 0.54b1",
78-
"opentelemetry-instrumentation-system-metrics == 0.54b1",
79-
"opentelemetry-instrumentation-tornado == 0.54b1",
80-
"opentelemetry-instrumentation-tortoiseorm == 0.54b1",
81-
"opentelemetry-instrumentation-urllib == 0.54b1",
82-
"opentelemetry-instrumentation-urllib3 == 0.54b1",
83-
"opentelemetry-instrumentation-wsgi == 0.54b1",
84-
"opentelemetry-instrumentation-cassandra == 0.54b1",
37+
"opentelemetry-distro == 0.48b0",
38+
"opentelemetry-propagator-ot-trace == 0.48b0",
39+
"opentelemetry-instrumentation == 0.48b0",
40+
"opentelemetry-instrumentation-aws-lambda == 0.48b0",
41+
"opentelemetry-instrumentation-aio-pika == 0.48b0",
42+
"opentelemetry-instrumentation-aiohttp-client == 0.48b0",
43+
"opentelemetry-instrumentation-aiopg == 0.48b0",
44+
"opentelemetry-instrumentation-asgi == 0.48b0",
45+
"opentelemetry-instrumentation-asyncpg == 0.48b0",
46+
"opentelemetry-instrumentation-boto == 0.48b0",
47+
"opentelemetry-instrumentation-boto3sqs == 0.48b0",
48+
"opentelemetry-instrumentation-botocore == 0.48b0",
49+
"opentelemetry-instrumentation-celery == 0.48b0",
50+
"opentelemetry-instrumentation-confluent-kafka == 0.48b0",
51+
"opentelemetry-instrumentation-dbapi == 0.48b0",
52+
"opentelemetry-instrumentation-django == 0.48b0",
53+
"opentelemetry-instrumentation-elasticsearch == 0.48b0",
54+
"opentelemetry-instrumentation-falcon == 0.48b0",
55+
"opentelemetry-instrumentation-fastapi == 0.48b0",
56+
"opentelemetry-instrumentation-flask == 0.48b0",
57+
"opentelemetry-instrumentation-grpc == 0.48b0",
58+
"opentelemetry-instrumentation-httpx == 0.48b0",
59+
"opentelemetry-instrumentation-jinja2 == 0.48b0",
60+
"opentelemetry-instrumentation-kafka-python == 0.48b0",
61+
"opentelemetry-instrumentation-logging == 0.48b0",
62+
"opentelemetry-instrumentation-mysql == 0.48b0",
63+
"opentelemetry-instrumentation-mysqlclient == 0.48b0",
64+
"opentelemetry-instrumentation-pika == 0.48b0",
65+
"opentelemetry-instrumentation-psycopg2 == 0.48b0",
66+
"opentelemetry-instrumentation-pymemcache == 0.48b0",
67+
"opentelemetry-instrumentation-pymongo == 0.48b0",
68+
"opentelemetry-instrumentation-pymysql == 0.48b0",
69+
"opentelemetry-instrumentation-pyramid == 0.48b0",
70+
"opentelemetry-instrumentation-redis == 0.48b0",
71+
"opentelemetry-instrumentation-remoulade == 0.48b0",
72+
"opentelemetry-instrumentation-requests == 0.48b0",
73+
"opentelemetry-instrumentation-sqlalchemy == 0.48b0",
74+
"opentelemetry-instrumentation-sqlite3 == 0.48b0",
75+
"opentelemetry-instrumentation-starlette == 0.48b0",
76+
"opentelemetry-instrumentation-system-metrics == 0.48b0",
77+
"opentelemetry-instrumentation-tornado == 0.48b0",
78+
"opentelemetry-instrumentation-tortoiseorm == 0.48b0",
79+
"opentelemetry-instrumentation-urllib == 0.48b0",
80+
"opentelemetry-instrumentation-urllib3 == 0.48b0",
81+
"opentelemetry-instrumentation-wsgi == 0.48b0",
82+
"opentelemetry-instrumentation-cassandra == 0.48b0",
8583
]
8684

8785
[project.optional-dependencies]
@@ -111,4 +109,4 @@ include = [
111109
]
112110

113111
[tool.hatch.build.targets.wheel]
114-
packages = ["src/amazon"]
112+
packages = ["src/amazon"]

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/loggertwo.log

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Meant to be run from aws-otel-python-instrumentation/contract-tests.
2+
# Assumes existence of dist/aws_opentelemetry_distro-<pkg_version>-py3-none-any.whl.
3+
# Assumes filename of aws_opentelemetry_distro-<pkg_version>-py3-none-any.whl is passed in as "DISTRO" arg.
4+
FROM python:3.10
5+
WORKDIR /mcp
6+
COPY ./dist/$DISTRO /mcp
7+
COPY ./contract-tests/images/applications/mcp /mcp
8+
COPY ./aws-opentelemetry-distro/src/amazon/opentelemetry/distro/mcpinstrumentor /mcp/mcpinstrumentor
9+
10+
ENV PIP_ROOT_USER_ACTION=ignore
11+
ARG DISTRO
12+
13+
# Install your MCP instrumentor first (use -e for editable install)
14+
RUN pip install --upgrade pip && pip install -e ./mcpinstrumentor/
15+
16+
# Then install other requirements and the main distro
17+
RUN pip install -r requirements.txt && pip install ${DISTRO} --force-reinstall
18+
RUN opentelemetry-bootstrap -a install
19+
20+
# Without `-u`, logs will be buffered and `wait_for_logs` will never return.
21+
CMD ["opentelemetry-instrument", "python3", "-u", "./simple_client.py"]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import asyncio
4+
import os
5+
from http.server import BaseHTTPRequestHandler, HTTPServer
6+
7+
from mcp import ClientSession, StdioServerParameters
8+
from mcp.client.stdio import stdio_client
9+
10+
11+
class MCPHandler(BaseHTTPRequestHandler):
12+
def do_GET(self):
13+
if self.path == "/mcp/echo":
14+
asyncio.run(self._call_mcp_tool("echo", {"text": "Hello from HTTP request!"}))
15+
self.send_response(200)
16+
self.end_headers()
17+
else:
18+
self.send_response(404)
19+
self.end_headers()
20+
21+
async def _call_mcp_tool(self, tool_name, arguments):
22+
server_env = {
23+
"OTEL_PYTHON_DISTRO": "aws_distro",
24+
"OTEL_PYTHON_CONFIGURATOR": "aws_configurator",
25+
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", ""),
26+
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
27+
"OTEL_TRACES_SAMPLER": "always_on",
28+
"OTEL_METRICS_EXPORTER": "none",
29+
"OTEL_LOGS_EXPORTER": "none",
30+
}
31+
server_params = StdioServerParameters(
32+
command="opentelemetry-instrument", args=["python3", "simple_mcp_server.py"], env=server_env
33+
)
34+
async with stdio_client(server_params) as (read, write):
35+
async with ClientSession(read, write) as session:
36+
await session.initialize()
37+
result = await session.call_tool(tool_name, arguments)
38+
return result
39+
40+
41+
if __name__ == "__main__":
42+
print("Ready")
43+
server = HTTPServer(("0.0.0.0", 8080), MCPHandler)
44+
server.serve_forever()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from fastmcp import FastMCP
4+
5+
# Create FastMCP server instance
6+
mcp = FastMCP("Simple MCP Server")
7+
8+
9+
@mcp.tool(name="echo", description="Echo the provided text")
10+
def echo(text: str) -> str:
11+
"""Echo the provided text"""
12+
return f"Echo: {text}"
13+
14+
15+
if __name__ == "__main__":
16+
mcp.run()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[project]
2+
name = "mcp-client-app"
3+
description = "Simple MCP client that calls echo tool for testing MCP instrumentation"
4+
version = "1.0.0"
5+
license = "Apache-2.0"
6+
requires-python = ">=3.10"
7+
dependencies = [
8+
"mcp>=1.1.0",
9+
"fastmcp>=0.1.0"
10+
]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
mcp>=1.1.0
2+
fastmcp>=0.1.0
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from typing_extensions import override
4+
5+
from amazon.base.contract_test_base import ContractTestBase
6+
from opentelemetry.proto.trace.v1.trace_pb2 import Span
7+
8+
9+
class MCPTest(ContractTestBase):
10+
11+
@override
12+
@staticmethod
13+
def get_application_image_name() -> str:
14+
return "aws-application-signals-tests-mcp-app"
15+
16+
def test_mcp_echo_tool(self):
17+
"""Test MCP echo tool call creates proper spans"""
18+
self.do_test_requests("mcp/echo", "GET", 200, 0, 0, tool_name="echo")
19+
20+
@override
21+
def _assert_aws_span_attributes(self, resource_scope_spans, path: str, **kwargs) -> None:
22+
pass
23+
24+
@override
25+
def _assert_semantic_conventions_span_attributes(
26+
self, resource_scope_spans, method: str, path: str, status_code: int, **kwargs
27+
) -> None:
28+
29+
tool_name = kwargs.get("tool_name", "echo")
30+
initialize_client_span = None
31+
list_tools_client_span = None
32+
list_tools_server_span = None
33+
call_tool_client_span = None
34+
call_tool_server_span = None
35+
36+
for resource_scope_span in resource_scope_spans:
37+
span = resource_scope_span.span
38+
39+
if span.name == "client.send_request" and span.kind == Span.SPAN_KIND_CLIENT:
40+
for attr in span.attributes:
41+
if attr.key == "mcp.initialize" and attr.value.bool_value:
42+
initialize_client_span = span
43+
break
44+
elif attr.key == "mcp.list_tools" and attr.value.bool_value:
45+
list_tools_client_span = span
46+
break
47+
elif attr.key == "mcp.call_tool" and attr.value.bool_value:
48+
call_tool_client_span = span
49+
break
50+
51+
elif span.name == "tools/list" and span.kind == Span.SPAN_KIND_SERVER:
52+
list_tools_server_span = span
53+
elif span.name == f"tools/{tool_name}" and span.kind == Span.SPAN_KIND_SERVER:
54+
call_tool_server_span = span
55+
56+
# Validate initialize client span (no server span expected)
57+
self.assertIsNotNone(initialize_client_span, "Initialize client span not found")
58+
self.assertEqual(initialize_client_span.kind, Span.SPAN_KIND_CLIENT)
59+
60+
init_attributes = {attr.key: attr.value for attr in initialize_client_span.attributes}
61+
self.assertIn("mcp.initialize", init_attributes)
62+
self.assertTrue(init_attributes["mcp.initialize"].bool_value)
63+
64+
# Validate list tools client span
65+
self.assertIsNotNone(list_tools_client_span, "List tools client span not found")
66+
self.assertEqual(list_tools_client_span.kind, Span.SPAN_KIND_CLIENT)
67+
68+
list_client_attributes = {attr.key: attr.value for attr in list_tools_client_span.attributes}
69+
self.assertIn("mcp.list_tools", list_client_attributes)
70+
self.assertTrue(list_client_attributes["mcp.list_tools"].bool_value)
71+
72+
# Validate list tools server span
73+
self.assertIsNotNone(list_tools_server_span, "List tools server span not found")
74+
self.assertEqual(list_tools_server_span.kind, Span.SPAN_KIND_SERVER)
75+
76+
list_server_attributes = {attr.key: attr.value for attr in list_tools_server_span.attributes}
77+
self.assertIn("mcp.list_tools", list_server_attributes)
78+
self.assertTrue(list_server_attributes["mcp.list_tools"].bool_value)
79+
80+
# Validate call tool client span
81+
self.assertIsNotNone(call_tool_client_span, f"Call tool client span for {tool_name} not found")
82+
self.assertEqual(call_tool_client_span.kind, Span.SPAN_KIND_CLIENT)
83+
84+
call_client_attributes = {attr.key: attr.value for attr in call_tool_client_span.attributes}
85+
self.assertIn("mcp.call_tool", call_client_attributes)
86+
self.assertTrue(call_client_attributes["mcp.call_tool"].bool_value)
87+
self.assertIn("aws.remote.operation", call_client_attributes)
88+
self.assertEqual(call_client_attributes["aws.remote.operation"].string_value, tool_name)
89+
90+
# Validate call tool server span
91+
self.assertIsNotNone(call_tool_server_span, f"Call tool server span for {tool_name} not found")
92+
self.assertEqual(call_tool_server_span.kind, Span.SPAN_KIND_SERVER)
93+
94+
call_server_attributes = {attr.key: attr.value for attr in call_tool_server_span.attributes}
95+
self.assertIn("mcp.call_tool", call_server_attributes)
96+
self.assertTrue(call_server_attributes["mcp.call_tool"].bool_value)
97+
98+
# Validate distributed tracing for paired spans
99+
self.assertEqual(
100+
list_tools_server_span.trace_id,
101+
list_tools_client_span.trace_id,
102+
"List tools client and server spans should have the same trace ID",
103+
)
104+
self.assertEqual(
105+
call_tool_server_span.trace_id,
106+
call_tool_client_span.trace_id,
107+
"Call tool client and server spans should have the same trace ID",
108+
)
109+
110+
@override
111+
def _assert_metric_attributes(self, resource_scope_metrics, metric_name: str, expected_sum: int, **kwargs) -> None:
112+
pass

0 commit comments

Comments
 (0)