Diagnosing Test Failures at Scale: Correlating Execution Evidence Across Ephemeral Runners
Learn how to capture test-failure artifacts, correlate distributed logs, preserve evidence of retries, and securely store CI/CD diagnostics.
Intermittent test failures in a distributed CI/CD environment can slow down development and make failures harder to investigate. When an automated test suite fails on a local dev or staging machine, an engineer can view local logs, attach a debugger, or manually follow state changes. But when tests execute in a temporary container, AWS Lambda, or an agentless runner, post-mortem debugging is significantly more involved.
In a traditional setup, a failed build only shows a generic assertion error (504, ElementNotFoundException, etc.). An engineer may need to manually sift through CloudWatch log groups for an approximate timestamp, query Splunk, or reason about state changes across downstream microservices to understand why the test failed. Once the log retention period expires or if the execution did not have a shared, identifiable correlation ID, the engineer has no further information about the cause of the failure.
An enterprise test framework should save the test results and logs before the test runner shuts down.
This article explores how to capture rich failure artifacts, stitch distributed logs (via correlation ID), and preserve retry states per-attempt.
1. Capturing Evidence Before the Ephemeral Runner Exits
Capturing telemetry on failure seems trivial, but ephemeral environments introduce special exit modes which may skip cleanup routines:
- Assertion failures: The framework captures the exception; standard teardown hooks execute
- Execution timeouts: The process or handler is externally aborted; finalizers may be skipped
- Hard kills (OOM/SIGKILL): The underlying container or VM is killed; no in-process code executed
Your test harness needs to capture evidence in gracefully terminated failure modes and before hard timeouts
Managing Runner Lifecycles
- AWS Lambda: A test runner deployed as a Lambda must check context.get_remaining_time_in_millis(). If it falls below a certain threshold (2,000 ms), it must terminate early, flush diagnostic buffers, and write metadata to S3. Your ephemeral /tmp storage defaults to 512 MB; holding large trace files or video recordings in memory would lead to out-of-memory (OOM) errors, so artifacts should be streamed directly to object store.
- AWS Fargate/ECS: When a task is stopped, it will receive a SIGTERM followed by a SIGKILL after a configured stopTimeout (30 seconds). Signal handlers in your test suite must capture SIGTERM and begin uploading artifacts.
- CI Build Agents (Bamboo/Jenkins): Agent-side logs are ephemeral and will be deleted when the agent's workspace is cleaned. Remote execution tasks should push telemetry to central storage (like AWS S3) and pass back the stable HTTP reference, not the local agent's file path.
Pytest Hook Architecture
In a Python setup, pairing a pytest_runtest_makereport hookwrapper with an autouse fixture guarantees that failure status is captured and attached to the test item before final teardown.
# conftest.py
import pytest
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# Execute all other hooks to obtain the report object
outcome = yield
rep = outcome.get_result()
# Store the report on the item for fixture access
setattr(item, f"rep_{rep.when}", rep)
@pytest.fixture(autouse=True)
def failure_evidence_collector(request):
yield
# Code below executes during fixture teardown
rep_call = getattr(request.node, "rep_call", None)
if rep_call and rep_call.failed:
execution_id = request.config.getoption("--execution-id", default="local")
attempt = getattr(request.node, "execution_count", 1)
# Extract environment details captured at run start
env_metadata = {
"test_name": request.node.name,
"stage": getattr(request.node, "current_stage", "unknown"),
"commit_hash": os.getenv("GIT_COMMIT", "HEAD"),
"aws_request_id": getattr(request.node, "aws_request_id", None)
}
upload_failure_artifacts(execution_id, attempt, env_metadata)
2. Run IDs and Cross-Service Correlation
Although capturing client-side logs is easy, if an integration test makes an HTTP call to a microservice architecture, the underlying defect may be found in downstream service logs. Without a unified identifier, correlating a client-side test failure with backend anomalies requires time-consuming manual timestamp matching.
Propagating the Correlation Context
To stitch frontend execution to backend telemetry, every outgoing request from the test runner must inject a unique tracking ID into transport headers.
- HTTP headers: Standardize on custom headers such as X-Correlation-ID or X-Run-ID. Initialize this ID once on the test client's session object so that every HTTP call automatically inherits it
- OpenTelemetry baggage: The standard W3C Trace Context (traceparent) requires a rigid 32-hex trace ID and 16-hex span ID format. CI build keys (e.g., PROJECT-JOB1-102) will not fit in standard trace fields. Instead, carry build context in the W3C baggage header (baggage: ci_build_key=PROJECT-JOB1-102,test_id=test_user_checkout)
- Async boundaries (SQS/SNS): HTTP headers do not automatically transition across message queues. When a test triggers an async workflow via AWS SQS, inject correlation metadata in the Message attributes. If there is an SNS in front of SQS, enable raw message Delivery in order to prevent SNS from wrapping the message payload in a JSON envelope (SQS attributes would need to be explicitly parsed by the consumer).
- Unowned platform services: Managed components like AWS API Gateway or Application Load Balancers (ALBs) will not process your application headers, but they will include their own tracking keys. API Gateway returns x-amzn-requestid in every response header, while ALBs preserve incoming X-Amzn-Trace-Id headers and record them in access logs.
When an assertion fails, the test framework outputs a pre-formatted log query targeting the exact correlation key, enabling one-click diagnostic retrieval in CloudWatch Logs Insights or Splunk.
-- CloudWatch Logs Insights Query
fields @timestamp, @message, service_name, log_level, error.type
| filter correlation_id = "BUILD-8492-test_checkout_flow-att1"
| sort @timestamp asc
| limit 500
Fallback strategy: For legacy third-party microservices that do not accept or log correlation headers, configure the evidence collector to generate a time window query that is constrained to the specific UTC start and end timestamps of that specific test case execution.
3. Retries Kept Per Attempt
Retries as implemented, say, by pytest-rerunfailures are often used to mitigate the risk of infrastructure-related failures, but such practice has its costs: retrying all tests that failed on the first attempt may mask intermittent bugs in the application under test. If a test fails on the first attempt but passes on the retry, the build will be marked as successful, even though the intermittent bug may still be present.
If a test failed on attempt 1 due to a race condition but passed on attempt 2 because a resource freed up, overwriting attempt 1's evidence erases the bug's trace. Standard JUnit XML output often only records the final execution state, hiding the initial failure.
Never write diagnostic files to a static path (e.g., s3://bucket/logs/test_checkout.log). Telemetry storage paths must include both the run identifier and the attempt counter.
s3://ci-test-evidence-bucket/
└── build-key=PROJECT-8492/
└── test_checkout_user/
├── attempt-1/
│ ├── metadata.json
│ ├── client_session.log
│ └── failure_screenshot.png
└── attempt-2/
├── metadata.json
└── client_session.log
Once per-attempt isolation is implemented, diff analysis can be performed:
// S3 Path: build-key=PROJECT-8492/test_checkout_user/attempt-1/metadata.json
{
"test_name": "test_checkout_user",
"execution_id": "PROJECT-8492",
"attempt": 1,
"status": "FAILED",
"duration_seconds": 12.4,
"http_status_code": 503,
"target_host": "ip-10-0-4-12.ec2.internal",
"failure_reason": "ServiceUnavailable: Database connection pool exhausted"
}
If attempt 1 fails with a 503 Service Unavailable from a particular host, and attempt 2 succeeds against a different host, the delta immediately indicates a node-specific configuration drift or pool exhaustion, not a code-level assertion flaw.
4. Keeping Artifacts Safe, Small, and Reachable
Collecting unlimited telemetry rapidly leads to two major issues: credential leakage and excessive storage costs.
Automated Redaction at Write Time
Diagnostic artifacts (typically HTTP request/response dumps) often contain authorization tokens, cookies, or personal data. They must be redacted from memory before writing to disk:
- Strip headers that match Authorization, Cookie, X-Api-Key, or Set-Cookie.
- Remove signature tokens from URL parameters. Note that AWS S3 Presigned URLs act as credentials, and logging them directly would accidentally expose an ability to read/write to any S3 bucket the application has access to.
Storage Optimization and Retention
To keep storage costs at enterprise scale under control, enforce explicit lifecycle and collection rules:
- Selective retention: only store full-payload logs and screenshots if a test failed. Store only a short tail of a log output for passing tests.
- S3 Lifecycle Rules: configure S3 bucket lifecycle rules to delete failed test artifacts 30 days after creation, and passing test run artifacts 3 days after creation. It should be configured with either prefix- or tag-based policies.
- Do not put raw S3 presigned links inside JUnit XML or CI failure logs. Create links using a stable internal proxy/route that is capable of signing requests dynamically upon access. Presigned links created with temporary IAM role credentials will become invalid as soon as the web server process that issued them reloads. It would make sense to have it turned into a dead link if an engineer looks at a CI failure several hours (or days) after its occurrence.
Failure Summary Surface
Inject the direct artifact link and correlation ID straight into the framework's assertion exception message. Because virtually all CI engines parse and display the top-level exception string in build summaries, engineers can jump directly to the evidence:
# Custom assertion failure formatter
def raise_with_evidence(message: str, correlation_id: str, evidence_url: str):
formatted_msg = (
f"{message}\n"
f"--------------------------------------------------\n"
f"Correlation ID : {correlation_id}\n"
f"Evidence Bundle: {evidence_url}\n"
f"--------------------------------------------------"
)
raise AssertionError(formatted_msg)
Architectural Limitations to Consider
While evidence collection is made dramatically easier by the structured nature of the solution, there are a few boundary conditions that should be considered:
- Hard OS/platform kills: An AWS Lambda function reaching its 15-minute hard limit or worker node power loss will prevent in-process code execution. Secondary monitoring (container stdout monitoring via CloudWatch/Datadog) becomes a necessary alternative.
- Unowned external third parties: SaaS downstream APIs or gateway services will not instrument custom correlation IDs. In these scenarios, evidence would be strictly client-side HTTP request timestamp logging.
- Redaction leakage: Regex sanitization could miss non-standard secret key formats in unstructured response bodies.
References
- W3C Recommendation: Trace Context - W3C Recommendation (2020).
- Luo, Q., Hariri, F., Eloussi, L., & Marinov, D.: "An Empirical Analysis of Flaky Tests" (ACM SIGSOFT International Symposium on Foundations of Software Engineering, 2014).
- Google Testing Blog: "Flaky Tests at Google and How We Mitigate Them" (2016).
- AWS Documentation: AWS Lambda Context Object in Python & ECS Task Lifecycle.
- Pytest Documentation: Writing and using plugins & pytest-rerunfailures specification.
Opinions expressed by DZone contributors are their own.