Chapter 17Lesson 02~245 minutes

Screenshots, Logs, Network Evidence, and Failure Diagnostics: Guided Hands-On Workflow

This lesson turns the evidence model into a reproducible workflow. You will generate a loopback-only fixture with synthetic application, JavaScript, and network-like failure controls; inspect the session first; capture evidence before teardown; redact a fake secret; and verify that every artifact shares the same test correlation.

Hands-on labScreenshotsBrowser logsRedactionBiDi preview

Learning objectives

  • Create and run a disposable localhost evidence fixture with deterministic failure controls.
  • Configure Chromium browser logging explicitly and discover available log types instead of assuming support.
  • Capture a first-failure bundle containing screenshot, URL/title, targeted DOM state, capabilities, browser logs, and resource timing.
  • Redact synthetic secrets and sensitive query parameters before writing textual artifacts.
  • Preview current WebDriver BiDi console/JavaScript-error handlers without making BiDi mandatory for Chapter 17.

1. Preflight and disposable workspace

Use Python 3.10+, Selenium Python 4.47.0, and a locally installed supported Chromium-family browser. Selenium Manager resolves the normal local driver path. The AUT binds only to 127.0.0.1:8781; the secret string is intentionally fake; no real account, proxy, TLS override, or external service is used.

python -m venv .venv
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# macOS/Linux: source .venv/bin/activate
python -m pip install "selenium==4.47.0"
Do not reuse a real browser profile

This lab needs no personal profile, saved login, extension state, or production cookie. Let WebDriver create a disposable session.

2. Generate the local fixture

Save the following as make_fixture.py and run it once. It writes an HTML fixture plus a tiny Python server. The server records its own raw synthetic request log so the network-like case has evidence independent of the browser.

from pathlib import Path

ROOT = Path("chapter17_fixture")
SITE = ROOT / "site"
SITE.mkdir(parents=True, exist_ok=True)

(SITE / "index.html").write_text(r'''<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Evidence Engineering Lab</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; background: #10151c; color: #eef4fb; }
    main { max-width: 900px; margin: 0 auto; padding: 28px; }
    .panel { border: 1px solid #415166; border-radius: 14px; padding: 18px; margin: 16px 0; }
    button { margin: 5px; padding: 10px 14px; }
    code { overflow-wrap: anywhere; }
  </style>
</head>
<body>
<main>
  <h1 data-testid="heading">Evidence Engineering Lab</h1>
  <p data-testid="test-id"></p>
  <div class="panel">
    <button data-testid="healthy" type="button">Healthy path</button>
    <button data-testid="app-error" type="button">Application error</button>
    <button data-testid="js-error" type="button">Client JavaScript error</button>
    <button data-testid="network-error" type="button">Network-like failure</button>
  </div>
  <div class="panel">
    <strong>Status</strong>
    <p data-testid="status" data-state="idle">idle</p>
    <strong>Correlation</strong>
    <code data-testid="correlation"></code>
  </div>
</main>
<script>
const params = new URLSearchParams(location.search);
const testId = params.get('test_id') || 'manual';
const syntheticSecret = 'demo-secret-123';
const q = id => document.querySelector(`[data-testid="${id}"]`);
q('test-id').textContent = `test_id=${testId}`;
q('correlation').textContent = testId;

function setState(state, text) {
  q('status').dataset.state = state;
  q('status').textContent = text;
}

q('healthy').addEventListener('click', () => setState('ok', 'healthy'));
q('app-error').addEventListener('click', () => {
  setState('app-error', 'synthetic application validation failed');
  console.warn(`app validation rejected test_id=${testId}`);
});
q('js-error').addEventListener('click', () => {
  setState('js-triggered', 'client exception triggered');
  queueMicrotask(() => {
    throw new Error(`synthetic-client-error test_id=${testId} token=${syntheticSecret}`);
  });
});
q('network-error').addEventListener('click', async () => {
  setState('network-pending', 'request in flight');
  try {
    await fetch(`/api/drop?test_id=${encodeURIComponent(testId)}&token=${encodeURIComponent(syntheticSecret)}`,
      {headers: {'X-Test-Id': testId}});
    setState('unexpected-success', 'unexpected response');
  } catch (error) {
    console.error(`synthetic-network-error test_id=${testId} token=${syntheticSecret}`, error);
    setState('network-error', 'synthetic transport failed');
  }
});
</script>
</body>
</html>''', encoding="utf-8")

(ROOT / "serve.py").write_text(r'''from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlsplit, parse_qs
from datetime import datetime, timezone
import json
import socket

ROOT = Path(__file__).resolve().parent
SITE = ROOT / "site"
RAW_LOG = ROOT / "server-raw.jsonl"
HOST, PORT = "127.0.0.1", 8781

class Handler(SimpleHTTPRequestHandler):
    def translate_path(self, path):
        raw = super().translate_path(path)
        rel = Path(raw).relative_to(Path.cwd())
        return str(SITE / rel)

    def log_message(self, format, *args):
        pass

    def _record(self, outcome):
        parts = urlsplit(self.path)
        row = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "method": self.command,
            "path": parts.path,
            "query": parse_qs(parts.query),
            "x_test_id": self.headers.get("X-Test-Id"),
            "outcome": outcome,
        }
        with RAW_LOG.open("a", encoding="utf-8") as fh:
            fh.write(json.dumps(row) + "\n")

    def do_GET(self):
        if self.path.startswith("/api/drop"):
            self._record("connection-dropped")
            try:
                self.connection.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
            self.connection.close()
            return
        self._record("static-response")
        return super().do_GET()

server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Evidence fixture: http://{HOST}:{PORT}/")
try:
    server.serve_forever()
except KeyboardInterrupt:
    pass
finally:
    server.server_close()
''', encoding="utf-8")

print(ROOT.resolve())

The following example makes the Generate the local fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python make_fixture.py
python chapter17_fixture/serve.py
# Expected: Evidence fixture: http://127.0.0.1:8781/

Keep the server terminal visible. Opening the URL manually should show four buttons and an idle status.

3. Understand what each control changes

The following table organizes the key choices and evidence for Understand what each control changes. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Control Browser/AUT change Independent evidence
Healthy path DOM status becomes ok No injected error
Application error DOM status becomes app-error Console warning may exist, but business state is primary
Client JavaScript error DOM status records trigger; microtask throws a synthetic Error Browser console / BiDi JS error when supported
Network-like failure Fetch targets /api/drop; server closes connection; DOM catches failure Server raw request log + browser failure symptom

4. Build a privacy-aware first-failure collector

Save the next program as guided_capture.py. It configures Chromium browser logging explicitly, but still checks driver.log_types. It never stores the fixture’s fake token in textual evidence: query parameters and log messages are sanitized before persistence.

from pathlib import Path
from datetime import datetime, timezone
from urllib.parse import urlsplit, parse_qsl, urlencode, urlunsplit
import json
import re
import selenium
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

BASE_URL = "http://127.0.0.1:8781/"
SECRET = "demo-secret-123"
ARTIFACTS = Path("evidence")
ARTIFACTS.mkdir(exist_ok=True)


def redact_text(value):
    text = str(value)
    text = text.replace(SECRET, "<redacted>")
    text = re.sub(r"(?i)(authorization:\\s*bearer\\s+)[^\\s]+", r"\\1<redacted>", text)
    text = re.sub(r"(?i)(token=)[^&\\s]+", r"\\1<redacted>", text)
    return text


def redact_url(url):
    parts = urlsplit(url)
    pairs = []
    for key, value in parse_qsl(parts.query, keep_blank_values=True):
        pairs.append((key, "<redacted>" if key.lower() in {"token", "secret", "key"} else value))
    return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(pairs), parts.fragment))


def safe_caps(caps):
    keep = ["browserName", "browserVersion", "platformName", "pageLoadStrategy"]
    return {key: caps.get(key) for key in keep if key in caps}


def read_browser_logs(driver):
    available = set(getattr(driver, "log_types", []))
    if "browser" not in available:
        return {"available": sorted(available), "entries": [], "note": "browser log type unavailable"}
    entries = []
    for row in driver.get_log("browser"):
        entries.append({k: redact_text(v) for k, v in row.items()})
    return {"available": sorted(available), "entries": entries}


def capture(driver, test_id, label):
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
    root = ARTIFACTS / test_id / f"{stamp}-{label}"
    root.mkdir(parents=True, exist_ok=False)

    driver.save_screenshot(str(root / "viewport.png"))
    status = driver.find_element(By.CSS_SELECTOR, '[data-testid="status"]')
    dom = {
        "status_text": status.text,
        "status_state": status.get_attribute("data-state"),
        "correlation": driver.find_element(By.CSS_SELECTOR, '[data-testid="correlation"]').text,
    }
    (root / "dom.json").write_text(json.dumps(dom, indent=2), encoding="utf-8")

    metadata = {
        "captured_at": stamp,
        "test_id": test_id,
        "label": label,
        "selenium": selenium.__version__,
        "session_id": driver.session_id,
        "url": redact_url(driver.current_url),
        "title": driver.title,
        "capabilities": safe_caps(driver.capabilities),
    }
    (root / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")

    logs = read_browser_logs(driver)
    (root / "browser-log.json").write_text(json.dumps(logs, indent=2), encoding="utf-8")

    resources = driver.execute_script("""
      return performance.getEntriesByType('resource').map(e => ({
        name: e.name, initiatorType: e.initiatorType, duration: e.duration
      }));
    """)
    sanitized = [{**row, "name": redact_url(row["name"])} for row in resources]
    (root / "resource-timing.json").write_text(json.dumps(sanitized, indent=2), encoding="utf-8")
    return root


options = webdriver.ChromeOptions()
# Chromium-specific logging capability. Check returned log_types; do not assume parity in every browser.
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
driver = webdriver.Chrome(options=options)
try:
    test_id = "guided-js-error"
    driver.get(f"{BASE_URL}?test_id={test_id}")
    print("selenium", selenium.__version__)
    print("session", driver.session_id)
    print("capabilities", safe_caps(driver.capabilities))
    print("log_types", driver.log_types)
    print("before", driver.current_url, driver.title)

    driver.find_element(By.CSS_SELECTOR, '[data-testid="js-error"]').click()
    WebDriverWait(driver, 3).until(
        lambda d: d.find_element(By.CSS_SELECTOR, '[data-testid="status"]').get_attribute("data-state") == "js-triggered"
    )
    bundle = capture(driver, test_id, "first-failure")
    print("bundle", bundle)
finally:
    driver.quit()

5. Run and inspect causality

The following example makes the Run and inspect causality behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python guided_capture.py
# Inspect:
# evidence/guided-js-error/<UTC>-first-failure/
#   viewport.png
#   dom.json
#   metadata.json
#   browser-log.json
#   resource-timing.json

Before the click, the script prints the negotiated browser version, platform, session ID, available log types, URL, and title. After the click, it waits on the fixture’s explicit data-state—not on a sleep—and captures before quit(). That ordering is the causal contract.

6. Interpret each file without overclaiming

The following table organizes the key choices and evidence for Interpret each file without overclaiming. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Artifact Expected observation Interpretation
metadata.json Session ID, browser version, redacted URL Identifies the exact session and environment
dom.json status_state = js-triggered The application-side trigger executed
viewport.png Visible client-exception state Visual evidence at capture time
browser-log.json Synthetic JS error when browser log type is supported Client runtime error correlated with the action
resource-timing.json Page resource observations, often empty for this JS-only case Do not invent network causality when no network action occurred

7. Verify redaction independently

Search textual artifacts for the fake secret. The correct result is no match.

from pathlib import Path

secret = "demo-secret-123"
leaks = []
for path in Path("evidence").rglob("*"):
    if path.is_file() and path.suffix in {".json", ".txt", ".md"}:
        if secret in path.read_text(encoding="utf-8"):
            leaks.append(str(path))
assert not leaks, leaks
print("redaction verified")

This check is intentionally independent of the collector. Security controls need verification, not just intent.

8. Optional preview: stream console and JavaScript errors with WebDriver BiDi

Current Selenium exposes high-level BiDi handlers through the script namespace when BiDi is enabled in browser options. This is a preview only; Chapter 18 develops event subscriptions, network events, contexts, lifecycle, and portability in depth.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

options = webdriver.ChromeOptions()
options.enable_bidi = True
driver = webdriver.Chrome(options=options)
messages = []
errors = []
try:
    driver.get("http://127.0.0.1:8781/?test_id=bidi-preview")
    console_id = driver.script.add_console_message_handler(messages.append)
    error_id = driver.script.add_javascript_error_handler(errors.append)

    driver.find_element(By.CSS_SELECTOR, '[data-testid="js-error"]').click()
    WebDriverWait(driver, 3).until(lambda _: errors)
    print("console events", len(messages))
    print("javascript errors", [entry.text for entry in errors])

    driver.script.remove_console_message_handler(console_id)
    driver.script.remove_javascript_error_handler(error_id)
finally:
    driver.quit()

The handler IDs matter: subscriptions are state. Remove handlers when their scenario ends so later tests do not inherit duplicate listeners or misleading events.

9. Network evidence: collect only what the hypothesis needs

The fixture’s network-like failure is intentionally diagnosed with three perspectives: the AUT reports network-error, the browser may report a fetch/console failure, and the server raw log proves that /api/drop was reached before the connection was closed. That is stronger than a screenshot and safer than indiscriminately storing every request header/body.

BiDi direction

WebDriver BiDi provides current cross-browser network-event APIs. The mandatory lab does not depend on them because Chapter 18 is where network subscriptions and event lifecycle are taught in full.

10. What changes on Grid or CI?

The browser may be remote while the evidence bundle is written on the test runner. Preserve returned session ID, browser/platform capabilities, Grid job correlation if available, and the CI attempt/shard. Never assume a node-local driver log is inside the runner workspace. Hosted vendor videos/HARs can be useful optional evidence, but the free local path remains complete.

11. Challenge: choose the evidence control, not a memorized sequence

Change the guided case from js-error to app-error. Before running, predict which artifacts should change and which should not. Your design should not require a browser console error to classify an application/business failure. Then switch to network-error and decide whether resource timing alone is sufficient. Explain your answer using independent layers.

12. Cleanup

The following example makes the Cleanup behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# Stop serve.py with Ctrl+C first.
# Keep evidence only if you intentionally want the synthetic lab artifacts.
python - <<'PY'
from pathlib import Path
import shutil
for p in [Path("chapter17_fixture"), Path("evidence")]:
    if p.exists(): shutil.rmtree(p)
print("lab workspace removed")
PY

WebDriver quit() ends the browser session. No personal profile, real credentials, production data, or external service was used.

13. Summary and bridge

You now have a causal capture order and a redacted evidence schema. Lesson 3 turns those mechanics into policy choices: when to capture, which screenshot/DOM/log/network level to retain, how to control cost, and when classic logs should give way to BiDi event streams.

Knowledge check

Why does the collector check driver.log_types?

Why capture before driver.quit()?

Why is the server request log useful in the network-like case?

Why is the fake token still redacted?

What Chapter 18 responsibility is deliberately not hidden inside this lab?

Next lesson

Screenshots, Logs, Network Evidence, and Failure Diagnostics: Configuration, Design Patterns, and Trade-Offs

Continue with Screenshots, Logs, Network Evidence, and Failure Diagnostics: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against Selenium primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0 and Python 3.10+, use a supported local Chromium-family browser and Selenium Manager, and target only a loopback synthetic AUT. Classic get_log() examples explicitly discover log_types and do not claim browser parity. WebDriver BiDi console/JavaScript-error APIs are labeled as a preview because Chapter 18 is dedicated to BiDi; paid browser-cloud videos/HARs, enterprise log platforms, and remote Grid observability are optional architecture only. No production target, real account, personal profile, TLS bypass, or real secret is required.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.