Skip to content

Commit c48045e

Browse files
authored
Publish integ tests results to cloudwatch (#1587)
1 parent 5314721 commit c48045e

3 files changed

Lines changed: 157 additions & 2 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env python3
2+
import sys
3+
import xml.etree.ElementTree as ET
4+
from datetime import datetime
5+
from dataclasses import dataclass
6+
from typing import Any, Literal, TypedDict
7+
import os
8+
import boto3
9+
10+
STRANDS_METRIC_NAMESPACE = 'Strands/Tests'
11+
12+
13+
14+
class Dimension(TypedDict):
15+
Name: str
16+
Value: str
17+
18+
19+
class MetricDatum(TypedDict):
20+
MetricName: str
21+
Dimensions: list[Dimension]
22+
Value: float
23+
Unit: str
24+
Timestamp: datetime
25+
26+
27+
@dataclass
28+
class TestResult:
29+
name: str
30+
classname: str
31+
duration: float
32+
outcome: Literal['failed', 'skipped', 'passed']
33+
34+
35+
def parse_junit_xml(xml_file_path: str) -> list[TestResult]:
36+
try:
37+
tree = ET.parse(xml_file_path)
38+
except FileNotFoundError:
39+
print(f"Warning: XML file not found: {xml_file_path}")
40+
return []
41+
except ET.ParseError as e:
42+
print(f"Warning: Failed to parse XML: {e}")
43+
return []
44+
45+
results = []
46+
root = tree.getroot()
47+
48+
for testcase in root.iter('testcase'):
49+
name = testcase.get('name')
50+
classname = testcase.get('classname')
51+
duration = float(testcase.get('time', 0.0))
52+
53+
if not name or not classname:
54+
continue
55+
56+
if testcase.find('failure') is not None or testcase.find('error') is not None:
57+
outcome = 'failed'
58+
elif testcase.find('skipped') is not None:
59+
outcome = 'skipped'
60+
else:
61+
outcome = 'passed'
62+
63+
results.append(TestResult(name, classname, duration, outcome))
64+
65+
return results
66+
67+
68+
def build_metric_data(test_results: list[TestResult], repository: str) -> list[MetricDatum]:
69+
metrics: list[MetricDatum] = []
70+
timestamp = datetime.utcnow()
71+
72+
for test in test_results:
73+
test_name = f"{test.classname}.{test.name}"
74+
dimensions: list[Dimension] = [
75+
Dimension(Name='TestName', Value=test_name),
76+
Dimension(Name='Repository', Value=repository)
77+
]
78+
79+
metrics.append(MetricDatum(
80+
MetricName='TestPassed',
81+
Dimensions=dimensions,
82+
Value=1.0 if test.outcome == 'passed' else 0.0,
83+
Unit='Count',
84+
Timestamp=timestamp
85+
))
86+
87+
metrics.append(MetricDatum(
88+
MetricName='TestFailed',
89+
Dimensions=dimensions,
90+
Value=1.0 if test.outcome == 'failed' else 0.0,
91+
Unit='Count',
92+
Timestamp=timestamp
93+
))
94+
95+
metrics.append(MetricDatum(
96+
MetricName='TestSkipped',
97+
Dimensions=dimensions,
98+
Value=1.0 if test.outcome == 'skipped' else 0.0,
99+
Unit='Count',
100+
Timestamp=timestamp
101+
))
102+
103+
metrics.append(MetricDatum(
104+
MetricName='TestDuration',
105+
Dimensions=dimensions,
106+
Value=test.duration,
107+
Unit='Seconds',
108+
Timestamp=timestamp
109+
))
110+
111+
return metrics
112+
113+
114+
def publish_metrics(metric_data: list[dict[str, Any]], region: str):
115+
cloudwatch = boto3.client('cloudwatch', region_name=region)
116+
117+
batch_size = 1000
118+
for i in range(0, len(metric_data), batch_size):
119+
batch = metric_data[i:i + batch_size]
120+
try:
121+
cloudwatch.put_metric_data(Namespace=STRANDS_METRIC_NAMESPACE, MetricData=batch)
122+
print(f"Published {len(batch)} metrics to CloudWatch")
123+
except Exception as e:
124+
print(f"Warning: Failed to publish metrics batch: {e}")
125+
126+
127+
def main():
128+
if len(sys.argv) != 3:
129+
print("Usage: python upload-integ-test-metrics.py <xml_file> <repository_name>")
130+
sys.exit(0)
131+
132+
xml_file = sys.argv[1]
133+
repository = sys.argv[2]
134+
region = os.environ.get('AWS_REGION', 'us-east-1')
135+
136+
test_results = parse_junit_xml(xml_file)
137+
if not test_results:
138+
print("No test results found")
139+
sys.exit(1)
140+
141+
print(f"Found {len(test_results)} test results")
142+
metric_data = build_metric_data(test_results, repository)
143+
publish_metrics(metric_data, region)
144+
145+
146+
if __name__ == '__main__':
147+
main()

.github/workflows/integration-test.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jobs:
3737
role-to-assume: ${{ secrets.STRANDS_INTEG_TEST_ROLE }}
3838
aws-region: us-east-1
3939
mask-aws-account-id: true
40+
4041
- name: Checkout head commit
4142
uses: actions/checkout@v6
4243
with:
@@ -57,3 +58,9 @@ jobs:
5758
id: tests
5859
run: |
5960
hatch test tests_integ
61+
62+
- name: Publish test metrics to CloudWatch
63+
if: always()
64+
run: |
65+
pip install --no-cache-dir boto3
66+
python .github/scripts/upload-integ-test-metrics.py ./build/test-results.xml ${{ github.event.repository.name }}

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ dependencies = [
149149
"pytest-asyncio>=1.0.0,<1.4.0",
150150
"pytest-timeout>=2.0.0,<3.0.0",
151151
"pytest-xdist>=3.0.0,<4.0.0",
152+
"pytest-timeout>=2.0.0,<3.0.0",
152153
"moto>=5.1.0,<6.0.0",
153154
]
154155

@@ -240,7 +241,7 @@ convention = "google"
240241
[tool.pytest.ini_options]
241242
testpaths = ["tests"]
242243
asyncio_default_fixture_loop_scope = "function"
243-
addopts = "--ignore=tests/strands/experimental/bidi --ignore=tests_integ/bidi"
244+
addopts = "--ignore=tests/strands/experimental/bidi --ignore=tests_integ/bidi --junit-xml=build/test-results.xml"
244245
timeout = 45
245246

246247

@@ -298,7 +299,7 @@ prepare = [
298299
"hatch run bidi-test:test-cov",
299300
]
300301

301-
[tools.hatch.envs.bidi-lint]
302+
[tool.hatch.envs.bidi-lint]
302303
template = "bidi"
303304

304305
[tool.hatch.envs.bidi-lint.scripts]

0 commit comments

Comments
 (0)