Skip to content

Commit 9d6a0a5

Browse files
authored
Set up contract tests (#40)
In this commit, we are setting up contract tests similar to those in the aws-otel-java-instrumentation repo. Initially, we are just committing the logic to run the mock collector and a single application that uses the requests library, but the tests are extensible to other frameworks. There are a number of small differences between these tests and the Java contract tests, most of which are language dependent: * I've disabled code style checks on three files: `mock_collector_service_pb2_grpc.py`, `mock_collector_service_pb2.py`, and`mock_collector_service_pb2.pyi`. These are generated GRPC files and no edits to these files are recommended after generating from proto files. I have made no edits to these files after generating with commands described in the mock collector README. * `requests_server.py` is based on the [`native-http-client/App.java`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/images/http-clients/native-http-client/src/main/java/com/amazon/sampleapp/App.java) * `mock_collector_client.py` is based on the [`MockCollectorClient.java`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/utils/MockCollectorClient.java) and [`ResourceScopeSignal.kt`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/contract-tests/src/test/kotlin/software/amazon/opentelemetry/appsignals/test/utils/ResourceScopeSignal.kt). Note that a lot of the serialization/deserialization logic is simplified in Python/gRPC. * `mock-collector` classes (e.g. `mock_collector_server`, `mock_collector_metrics_service`, etc are based on the [`mockcollector` java classes](https://github.com/aws-observability/aws-otel-java-instrumentation/tree/main/appsignals-tests/images/mock-collector/src/main/java/software/amazon/opentelemetry/appsignals/test/images/mockcollector). Callout that java uses an HTTP server wrapping gRPC servicers, while I'm just using a gRPC server and servicers as it was simpler to do so in Python. * `contract_test_base.py` is based on [`ContractTestBase.java`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/base/ContractTestBase.java). One substantial difference is that we are not passing information in about the agent, since both testcontainers and OTEL instrumentation mechanisms are quite different in Java vs Python. * `requests_test.py` is based on [`BaseHttpClientTest.java`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/httpclients/base/BaseHttpClientTest.java) and [`NativeHttpClientTest.java`](https://github.com/aws-observability/aws-otel-java-instrumentation/blob/main/appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/httpclients/nativehttpclient/NativeHttpClientTest.java) * Note that `PEER_SERVICE` does not appear to be supported by Python * Note that OTEL does not instrument basic HTTP Servers, so there are no server spans produced by the application and only client spans are produced `requests`. This is acceptable from a contract perspective, but does result in `AWS_LOCAL_OPERATION` being `InternalOperation` and `AWS_SPAN_KIND` being `LOCAL_ROOT` on spans/metrics (in metrics `AWS_SPAN_KIND` is `CLIENT`, this is expected) * Note that `NET_PEER_NAME` and `NET_PEER_PORT` are not populated by `requests` instrumentation, which appears to be an upstream bug. This results in `AWS_REMOTE_SERVICE` being `UnknownRemoteService` * `pylint: disable` summary: * `invalid-name` - only used where I do not have control over the method name (e.g. overrides) * `broad-exception-caught` - Used where we really do want to just catch everything and keep going * `no-self-use` - only used when defining methods that are designed to be overridden by subclasses. * `no-member` - Used for `Span.SPAN_KIND_CLIENT` - for some reason, pylint cannot detect this constant, likely related to gRPC/proto magic By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 476503d commit 9d6a0a5

27 files changed

+1131
-2
lines changed

.flake8

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ exclude =
2121
venv*/
2222
target
2323
__pycache__
24+
mock_collector_service_pb2.py
25+
mock_collector_service_pb2.pyi
26+
mock_collector_service_pb2_grpc.py

.isort.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ profile=black
1313
; )
1414
; docs: https://github.com/timothycrosley/isort#multi-line-output-modes
1515
multi_line_output=3
16-
skip=target
16+
skip=target,mock_collector_service_pb2_grpc.py,mock_collector_service_pb2.py,mock_collector_service_pb2.pyi
1717
skip_glob=**/gen/*,.venv*/*,venv*/*,.tox/*
1818
known_first_party=opentelemetry,amazon
1919
known_third_party=psutil,pytest

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension-pkg-whitelist=cassandra
77

88
# Add list of files or directories to be excluded. They should be base names, not
99
# paths.
10-
ignore=CVS,gen,Dockerfile,docker-compose.yml,README.md,requirements.txt
10+
ignore=CVS,gen,Dockerfile,docker-compose.yml,README.md,requirements.txt,mock_collector_service_pb2.py,mock_collector_service_pb2.pyi,mock_collector_service_pb2_grpc.py
1111

1212
# Add files or directories matching the regex patterns to be excluded. The
1313
# regex matches against base names, not paths.

contract-tests/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Introduction
2+
3+
This directory contain contract tests that exist to prevent regressions. They cover:
4+
* [OpenTelemetry semantic conventions](https://github.com/open-telemetry/semantic-conventions/).
5+
* Application Signals-specific attributes.
6+
7+
# How it works?
8+
9+
The tests present here rely on the auto-instrumentation of a sample application which will send telemetry signals to a mock collector. The tests will use the data collected by the mock collector to perform assertions and validate that the contracts are being respected.
10+
11+
# Types of tested frameworks
12+
13+
The frameworks and libraries that are tested in the contract tests should fall in the following categories (more can be added on demand):
14+
* http-servers - applications meant to test http servers (e.g. Django).
15+
* http-clients - applications meant to test http clients (e.g. requests).
16+
* aws-sdk - Applications meant to test the AWS SDK (e.g. botocore).
17+
* database-clients - Applications meant to test database clients (e.g. psychopg2).
18+
19+
When testing a framework, we will create a sample application. The sample applications are stored following this convention: `contract-tests/images/applications/<framework-name>`.
20+
21+
# Adding tests for a new library or framework
22+
23+
The steps to add a new test for a library or framework are:
24+
* Create a sample application.
25+
* The sample application should be created in `contract-tests/images/applications/<framework-name>`.
26+
* Implement a `pyproject.toml` (to ensure code style checks run), `Dockerfile`, and `requirements.txt` file. See the `requests` application for an example of this.
27+
* Add a test class for the sample application.
28+
* The test class should be created in `contract-tests/tests/amazon/<framework-name>`.
29+
* The test class should extend `contract_test_base.py`
30+
31+
# How to run the tests locally?
32+
33+
Pre-requirements:
34+
* Have `docker` installed and running - verify by running the `docker` command.
35+
* Ensure the `aws_opentelemetry_distro` wheel file exists in to `aws-otel-python-instrumentation/dist` folder
36+
37+
From `aws-otel-python-instrumentation` dir, execute:
38+
39+
```
40+
./contract-tests/set-up-contract-tests.sh
41+
pytest contract-tests/tests
42+
```
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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 public.ecr.aws/docker/library/python:3.11-slim
5+
WORKDIR /requests
6+
COPY ./dist/$DISTRO /requests
7+
COPY ./contract-tests/images/applications/requests /requests
8+
9+
ARG DISTRO
10+
RUN pip install -r requirements.txt && pip install ${DISTRO} --force-reinstall
11+
RUN opentelemetry-bootstrap -a install
12+
13+
# Without `-u`, logs will be buffered and `wait_for_logs` will never return.
14+
CMD ["opentelemetry-instrument", "python", "-u", "./requests_server.py"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "requests-server"
3+
description = "Simple server that relies on requests library"
4+
version = "1.0.0"
5+
license = "Apache-2.0"
6+
requires-python = ">=3.8"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import atexit
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
from threading import Thread
6+
7+
from requests import Response, request
8+
from typing_extensions import override
9+
10+
_PORT: int = 8080
11+
_NETWORK_ALIAS: str = "backend"
12+
_SUCCESS: str = "success"
13+
_ERROR: str = "error"
14+
_FAULT: str = "fault"
15+
16+
17+
class RequestHandler(BaseHTTPRequestHandler):
18+
@override
19+
# pylint: disable=invalid-name
20+
def do_GET(self):
21+
self.handle_request("GET")
22+
23+
@override
24+
# pylint: disable=invalid-name
25+
def do_POST(self):
26+
self.handle_request("POST")
27+
28+
def handle_request(self, method: str):
29+
status_code: int
30+
if self.in_path(_NETWORK_ALIAS):
31+
if self.in_path(_SUCCESS):
32+
status_code = 200
33+
elif self.in_path(_ERROR):
34+
status_code = 400
35+
elif self.in_path(_FAULT):
36+
status_code = 500
37+
else:
38+
status_code = 404
39+
else:
40+
url: str = f"http://{_NETWORK_ALIAS}:{_PORT}/{_NETWORK_ALIAS}{self.path}"
41+
response: Response = request(method, url, timeout=20)
42+
status_code = response.status_code
43+
self.send_response_only(status_code)
44+
self.end_headers()
45+
46+
def in_path(self, sub_path: str):
47+
return sub_path in self.path
48+
49+
50+
def main() -> None:
51+
server_address: tuple[str, int] = ("0.0.0.0", _PORT)
52+
request_handler_class: type = RequestHandler
53+
requests_server: ThreadingHTTPServer = ThreadingHTTPServer(server_address, request_handler_class)
54+
atexit.register(requests_server.shutdown)
55+
server_thread: Thread = Thread(target=requests_server.serve_forever)
56+
server_thread.start()
57+
print("Ready")
58+
server_thread.join()
59+
60+
61+
if __name__ == "__main__":
62+
main()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
opentelemetry-distro==0.43b0
2+
opentelemetry-exporter-otlp-proto-grpc==1.22.0
3+
typing-extensions==4.9.0
4+
requests==2.31.0
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM public.ecr.aws/docker/library/python:3.11-slim
2+
WORKDIR /mock-collector
3+
COPY . /mock-collector
4+
5+
RUN pip install -r requirements.txt
6+
7+
# Without `-u`, logs will be buffered and `wait_for_logs` will never return.
8+
CMD ["python", "-u", "./mock_collector_server.py"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
### Overview
2+
3+
MockCollector mimics the behaviour of the actual OTEL collector, but stores export requests to be retrieved by contract tests.
4+
5+
### Protos
6+
To build protos:
7+
1. Run `pip install grpcio grpcio-tools`
8+
2. Change directory to `aws-otel-python-instrumentation/contract-tests/images/mock-collector/`
9+
3. Run: `python -m grpc_tools.protoc -I./protos --python_out=. --pyi_out=. --grpc_python_out=. ./protos/mock_collector_service.proto`
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from datetime import datetime, timedelta
4+
from logging import Logger, getLogger
5+
from time import sleep
6+
from typing import Callable, List, Set, TypeVar
7+
8+
from google.protobuf.internal.containers import RepeatedScalarFieldContainer
9+
from grpc import Channel, insecure_channel
10+
from mock_collector_service_pb2 import (
11+
ClearRequest,
12+
GetMetricsRequest,
13+
GetMetricsResponse,
14+
GetTracesRequest,
15+
GetTracesResponse,
16+
)
17+
from mock_collector_service_pb2_grpc import MockCollectorServiceStub
18+
19+
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ExportMetricsServiceRequest
20+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
21+
from opentelemetry.proto.metrics.v1.metrics_pb2 import Metric, ResourceMetrics, ScopeMetrics
22+
from opentelemetry.proto.trace.v1.trace_pb2 import ResourceSpans, ScopeSpans, Span
23+
24+
_logger: Logger = getLogger(__name__)
25+
_TIMEOUT_DELAY: timedelta = timedelta(seconds=20)
26+
_WAIT_INTERVAL_SEC: float = 0.1
27+
T: TypeVar = TypeVar("T")
28+
29+
30+
class ResourceScopeSpan:
31+
"""Data class used to correlate resources, scope and telemetry signals.
32+
33+
Correlate resource, scope and span
34+
"""
35+
36+
def __init__(self, resource_spans: ResourceSpans, scope_spans: ScopeSpans, span: Span):
37+
self.resource_spans: ResourceSpans = resource_spans
38+
self.scope_spans: ScopeSpans = scope_spans
39+
self.span: Span = span
40+
41+
42+
class ResourceScopeMetric:
43+
"""Data class used to correlate resources, scope and telemetry signals.
44+
45+
Correlate resource, scope and metric
46+
"""
47+
48+
def __init__(self, resource_metrics: ResourceMetrics, scope_metrics: ScopeMetrics, metric: Metric):
49+
self.resource_metrics: ResourceMetrics = resource_metrics
50+
self.scope_metrics: ScopeMetrics = scope_metrics
51+
self.metric: Metric = metric
52+
53+
54+
class MockCollectorClient:
55+
"""The mock collector client is used to interact with the Mock collector image, used in the tests."""
56+
57+
def __init__(self, mock_collector_address: str, mock_collector_port: str):
58+
channel: Channel = insecure_channel(f"{mock_collector_address}:{mock_collector_port}")
59+
self.client: MockCollectorServiceStub = MockCollectorServiceStub(channel)
60+
61+
def clear_signals(self) -> None:
62+
"""Clear all the signals in the backend collector"""
63+
self.client.clear(ClearRequest())
64+
65+
def get_traces(self) -> List[ResourceScopeSpan]:
66+
"""Get all traces that are currently stored in the collector
67+
68+
Returns:
69+
List of `ResourceScopeSpan` which is essentially a flat list containing all the spans and their related
70+
scope and resources.
71+
"""
72+
73+
def get_export() -> List[ExportTraceServiceRequest]:
74+
response: GetTracesResponse = self.client.get_traces(GetTracesRequest())
75+
serialized_traces: RepeatedScalarFieldContainer[bytes] = response.traces
76+
return list(map(ExportTraceServiceRequest.FromString, serialized_traces))
77+
78+
def wait_condition(exported: List[ExportTraceServiceRequest], current: List[ExportTraceServiceRequest]) -> bool:
79+
return 0 < len(exported) == len(current)
80+
81+
exported_traces: List[ExportTraceServiceRequest] = _wait_for_content(get_export, wait_condition)
82+
spans: List[ResourceScopeSpan] = []
83+
for exported_trace in exported_traces:
84+
for resource_span in exported_trace.resource_spans:
85+
for scope_span in resource_span.scope_spans:
86+
for span in scope_span.spans:
87+
spans.append(ResourceScopeSpan(resource_span, scope_span, span))
88+
return spans
89+
90+
def get_metrics(self, present_metrics: Set[str]) -> List[ResourceScopeMetric]:
91+
"""Get all metrics that are currently stored in the mock collector.
92+
93+
Returns:
94+
List of `ResourceScopeMetric` which is a flat list containing all metrics and their related scope and
95+
resources.
96+
"""
97+
98+
present_metrics_lower: Set[str] = {s.lower() for s in present_metrics}
99+
100+
def get_export() -> List[ExportMetricsServiceRequest]:
101+
response: GetMetricsResponse = self.client.get_metrics(GetMetricsRequest())
102+
serialized_metrics: RepeatedScalarFieldContainer[bytes] = response.metrics
103+
return list(map(ExportMetricsServiceRequest.FromString, serialized_metrics))
104+
105+
def wait_condition(
106+
exported: List[ExportMetricsServiceRequest], current: List[ExportMetricsServiceRequest]
107+
) -> bool:
108+
received_metrics: Set[str] = set()
109+
for exported_metric in current:
110+
for resource_metric in exported_metric.resource_metrics:
111+
for scope_metric in resource_metric.scope_metrics:
112+
for metric in scope_metric.metrics:
113+
received_metrics.add(metric.name.lower())
114+
return 0 < len(exported) == len(current) and present_metrics_lower.issubset(received_metrics)
115+
116+
exported_metrics: List[ExportMetricsServiceRequest] = _wait_for_content(get_export, wait_condition)
117+
metrics: List[ResourceScopeMetric] = []
118+
for exported_metric in exported_metrics:
119+
for resource_metric in exported_metric.resource_metrics:
120+
for scope_metric in resource_metric.scope_metrics:
121+
for metric in scope_metric.metrics:
122+
metrics.append(ResourceScopeMetric(resource_metric, scope_metric, metric))
123+
return metrics
124+
125+
126+
def _wait_for_content(get_export: Callable[[], List[T]], wait_condition: Callable[[List[T], List[T]], bool]) -> List[T]:
127+
# Verify that there is no more data to be received
128+
deadline: datetime = datetime.now() + _TIMEOUT_DELAY
129+
exported: List[T] = []
130+
131+
while deadline > datetime.now():
132+
try:
133+
current_exported: List[T] = get_export()
134+
if wait_condition(exported, current_exported):
135+
return current_exported
136+
exported = current_exported
137+
138+
sleep(_WAIT_INTERVAL_SEC)
139+
# pylint: disable=broad-exception-caught
140+
except Exception:
141+
_logger.exception("Error while reading content")
142+
143+
raise RuntimeError("Timeout waiting for content")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from queue import Queue
4+
from typing import List
5+
6+
from grpc import ServicerContext
7+
from typing_extensions import override
8+
9+
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
10+
ExportMetricsServiceRequest,
11+
ExportMetricsServiceResponse,
12+
)
13+
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2_grpc import MetricsServiceServicer
14+
15+
16+
class MockCollectorMetricsService(MetricsServiceServicer):
17+
_export_requests: Queue = Queue(maxsize=-1)
18+
19+
def get_requests(self) -> List[ExportMetricsServiceRequest]:
20+
with self._export_requests.mutex:
21+
return list(self._export_requests.queue)
22+
23+
def clear_requests(self) -> None:
24+
with self._export_requests.mutex:
25+
self._export_requests.queue.clear()
26+
27+
@override
28+
# pylint: disable=invalid-name
29+
def Export(self, request: ExportMetricsServiceRequest, context: ServicerContext) -> ExportMetricsServiceResponse:
30+
self._export_requests.put(request)
31+
return ExportMetricsServiceResponse()
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import atexit
4+
from concurrent.futures import ThreadPoolExecutor
5+
6+
from grpc import server
7+
from mock_collector_metrics_service import MockCollectorMetricsService
8+
from mock_collector_service import MockCollectorService
9+
from mock_collector_service_pb2_grpc import add_MockCollectorServiceServicer_to_server
10+
from mock_collector_trace_service import MockCollectorTraceService
11+
12+
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2_grpc import add_MetricsServiceServicer_to_server
13+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import add_TraceServiceServicer_to_server
14+
15+
16+
def main() -> None:
17+
mock_collector_server: server = server(thread_pool=ThreadPoolExecutor(max_workers=10))
18+
mock_collector_server.add_insecure_port("0.0.0.0:4315")
19+
20+
trace_collector: MockCollectorTraceService = MockCollectorTraceService()
21+
metrics_collector: MockCollectorMetricsService = MockCollectorMetricsService()
22+
mock_collector: MockCollectorService = MockCollectorService(trace_collector, metrics_collector)
23+
24+
add_TraceServiceServicer_to_server(trace_collector, mock_collector_server)
25+
add_MetricsServiceServicer_to_server(metrics_collector, mock_collector_server)
26+
add_MockCollectorServiceServicer_to_server(mock_collector, mock_collector_server)
27+
28+
mock_collector_server.start()
29+
atexit.register(mock_collector_server.stop, None)
30+
print("Ready")
31+
mock_collector_server.wait_for_termination(None)
32+
33+
34+
if __name__ == "__main__":
35+
main()

0 commit comments

Comments
 (0)