Related articles: Also see the developer-focused monitoring and alerting guide to pair with QA observability, production testing strategies that depend on good observability, and SLOs and error budgets as the targets your monitoring measures against.
QA Monitoring and Observability: How to Know Your Tests Are Actually Protecting Production
Your test suite just passed. All 847 tests are green. You deploy to production with confidence.
Three hours later, users start reporting errors. Your application is throwing exceptions that never appeared in testing. Support tickets pile up. Your "comprehensive" test suite missed something critical.
The problem? Your tests verified behavior, but they didn't observe how your application actually behaves in real-world conditions. For a full breakdown of the industry landscape, see our 2026 LLM Testing Buyers Guide.
This is the difference between testing and observability. Testing asks, "Does this work as expected?" Observability asks, "What is actually happening, and why?"
Monitoring and observability in QA means instrumenting your test environments and production systems to collect, analyze, and act on data about application behavior, performance, and errors�before they impact users.
This comprehensive guide teaches you how to implement monitoring and observability in your QA process, integrate error tracking platforms like Sentry, and build test intelligence that catches issues your tests can't predict.
Understanding the Observability Triad
Observability consists of three foundational pillars that work together to give you complete visibility into your systems:
| Pillar | What It Captures | Primary Tools | QA Use Cases |
|---|---|---|---|
| Logs | Discrete events with timestamps and context | ELK Stack, Datadog, Splunk | Debugging test failures, error correlation |
| Metrics | Numerical measurements over time | Prometheus, Grafana, CloudWatch | Performance tracking, resource utilization |
| Traces | Request flow through distributed systems | Jaeger, Zipkin, New Relic | End-to-end transaction analysis, bottleneck identification |
Why QA Needs All Three
Traditional QA focuses on assertions (pass/fail). Modern QA leverages observability to understand:
- Why a test failed (not just that it failed)
- How application behavior changes under different conditions
- Where performance bottlenecks exist
- When errors occur in production that tests can't reproduce
Implementing Error Tracking with Sentry
Sentry is the industry-standard platform for error tracking and monitoring. Here's how to integrate it into your QA workflow:
Setting Up Sentry for Test Environments
// sentry.config.ts
import * as Sentry from '@sentry/node';
import { ProfilingIntegration } from '@sentry/profiling-node';
export function initializeSentry(environment: 'test' | 'staging' | 'production') {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment,
// Set sample rates based on environment
tracesSampleRate: environment === 'production' ? 0.1 : 1.0,
profilesSampleRate: environment === 'production' ? 0.1 : 1.0,
integrations: [
// Capture console errors
new Sentry.Integrations.Console({ levels: ['error', 'warn'] }),
// Performance monitoring
new ProfilingIntegration(),
// HTTP request tracking
new Sentry.Integrations.Http({ tracing: true }),
],
// Add custom context to all events
beforeSend(event, hint) {
// Add test context if running in test environment
if (environment === 'test') {
event.tags = {
...event.tags,
testRun: process.env.TEST_RUN_ID,
testSuite: process.env.TEST_SUITE_NAME,
};
}
// Filter out known test errors
if (event.exception?.values?.[0]?.value?.includes('Expected test error')) {
return null; // Don't send to Sentry
}
return event;
},
});
}
// test-setup.ts - Initialize Sentry before tests
import { initializeSentry } from './sentry.config';
beforeAll(() => {
initializeSentry('test');
});
afterEach(async () => {
// Flush Sentry events after each test
await Sentry.flush(2000);
});
Tracking Test Failures with Context
// test-utils.ts
import * as Sentry from '@sentry/node';
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use, testInfo) => {
// Create transaction for this test
const transaction = Sentry.startTransaction({
name: testInfo.title,
op: 'test',
tags: {
testFile: testInfo.file,
project: testInfo.project.name,
},
});
Sentry.configureScope((scope) => {
scope.setSpan(transaction);
scope.setContext('test', {
title: testInfo.title,
file: testInfo.file,
retry: testInfo.retry,
});
});
// Add error listener
page.on('pageerror', (error) => {
Sentry.captureException(error, {
tags: {
source: 'page-error',
testName: testInfo.title,
},
contexts: {
page: {
url: page.url(),
title: await page.title().catch(() => 'unknown'),
},
},
});
});
try {
await use(page);
transaction.setStatus('ok');
} catch (error) {
transaction.setStatus('unknown_error');
// Capture screenshot on failure
const screenshot = await page.screenshot().catch(() => null);
Sentry.captureException(error, {
tags: {
testStatus: 'failed',
retry: testInfo.retry,
},
attachments: screenshot
? [
{
filename: 'failure-screenshot.png',
data: screenshot,
contentType: 'image/png',
},
]
: [],
});
throw error;
} finally {
transaction.finish();
}
},
});
Building Custom Test Monitoring Dashboards
Designing Effective QA Metrics
// metrics-collector.ts
interface TestMetrics {
testRunId: string;
timestamp: Date;
duration: number;
totalTests: number;
passed: number;
failed: number;
skipped: number;
flaky: number;
environment: string;
branch: string;
commit: string;
errors: ErrorMetric[];
performance: PerformanceMetric[];
}
interface ErrorMetric {
testName: string;
errorType: string;
errorMessage: string;
stackTrace: string;
screenshot?: string;
frequency: number;
}
class MetricsCollector {
private metrics: TestMetrics;
private startTime: number;
constructor() {
this.startTime = Date.now();
this.metrics = {
testRunId: generateRunId(),
timestamp: new Date(),
duration: 0,
totalTests: 0,
passed: 0,
failed: 0,
skipped: 0,
flaky: 0,
environment: process.env.TEST_ENV || 'local',
branch: process.env.GITHUB_REF_NAME || 'unknown',
commit: process.env.GITHUB_SHA || 'unknown',
errors: [],
performance: [],
};
}
recordTestResult(testInfo: TestInfo, result: TestResult) {
this.metrics.totalTests++;
if (result.status === 'passed') {
this.metrics.passed++;
} else if (result.status === 'failed') {
this.metrics.failed++;
this.recordError(testInfo, result);
} else if (result.status === 'skipped') {
this.metrics.skipped++;
}
// Detect flaky tests (passed on retry)
if (result.retry > 0 && result.status === 'passed') {
this.metrics.flaky++;
}
this.recordPerformance(testInfo, result);
}
private recordError(testInfo: TestInfo, result: TestResult) {
const error: ErrorMetric = {
testName: testInfo.title,
errorType: result.error?.name || 'Unknown',
errorMessage: result.error?.message || '',
stackTrace: result.error?.stack || '',
frequency: 1,
};
// Check if we've seen this error before
const existingError = this.metrics.errors.find(
(e) => e.errorMessage === error.errorMessage && e.testName === error.testName,
);
if (existingError) {
existingError.frequency++;
} else {
this.metrics.errors.push(error);
}
}
private recordPerformance(testInfo: TestInfo, result: TestResult) {
this.metrics.performance.push({
testName: testInfo.title,
duration: result.duration,
retries: result.retry,
});
}
async publish() {
this.metrics.duration = Date.now() - this.startTime;
// Send to monitoring backend
await fetch(process.env.METRICS_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.metrics),
});
// Also send to Sentry for correlation
Sentry.captureMessage('Test run completed', {
level: this.metrics.failed > 0 ? 'error' : 'info',
extra: this.metrics,
});
}
}
// Export singleton instance
export const metricsCollector = new MetricsCollector();
Observability Patterns for Different Test Types
Pattern 1: Integration Test Observability
// integration-test.spec.ts
import { test, expect } from './test-utils';
import * as Sentry from '@sentry/node';
test.describe('Checkout Integration Tests', () => {
test('should complete end-to-end purchase flow', async ({ page }) => {
const transaction = Sentry.startTransaction({
name: 'E2E Checkout Flow',
op: 'test.integration',
});
// Track each step as a span
const browseSpan = transaction.startChild({ op: 'browse-products' });
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
browseSpan.finish();
const cartSpan = transaction.startChild({ op: 'view-cart' });
await page.goto('/cart');
await expect(page.locator('.cart-item')).toBeVisible();
cartSpan.finish();
const checkoutSpan = transaction.startChild({ op: 'checkout' });
await page.click('[data-testid="checkout-button"]');
// Add custom measurements
const checkoutLoadTime = await page.evaluate(
() => performance.timing.loadEventEnd - performance.timing.navigationStart,
);
transaction.setMeasurement('checkout_load_time', checkoutLoadTime, 'millisecond');
checkoutSpan.finish();
transaction.finish();
});
});
Pattern 2: API Test Observability
// api-monitoring.ts
import axios, { AxiosInstance } from 'axios';
import * as Sentry from '@sentry/node';
export class ObservableAPIClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({ baseURL });
// Add request interceptor for tracing
this.client.interceptors.request.use((config) => {
const transaction = Sentry.getCurrentHub().getScope()?.getTransaction();
const span = transaction?.startChild({
op: 'http.client',
description: `${config.method?.toUpperCase()} ${config.url}`,
});
// Store span in request config for response interceptor
config.metadata = { span };
return config;
});
// Add response interceptor for metrics
this.client.interceptors.response.use(
(response) => {
const span = response.config.metadata?.span;
span?.setHttpStatus(response.status);
span?.setData('response.size', JSON.stringify(response.data).length);
span?.finish();
return response;
},
(error) => {
const span = error.config?.metadata?.span;
span?.setHttpStatus(error.response?.status || 500);
span?.finish();
// Capture API errors separately
Sentry.captureException(error, {
tags: {
api_endpoint: error.config?.url,
http_status: error.response?.status,
},
contexts: {
request: {
method: error.config?.method,
url: error.config?.url,
data: error.config?.data,
},
response: {
status: error.response?.status,
data: error.response?.data,
},
},
});
throw error;
},
);
}
// Wrapper methods with observability
async get<T>(url: string): Promise<T> {
return this.client.get<T>(url).then((res) => res.data);
}
async post<T>(url: string, data: any): Promise<T> {
return this.client.post<T>(url, data).then((res) => res.data);
}
}
Monitoring Test Environment Health
System Resource Monitoring
graph TD
A[Test Runner] --> B[Resource Monitor]
B --> C[CPU Usage]
B --> D[Memory Usage]
B --> E[Disk I/O]
B --> F[Network Latency]
C --> G[Metrics Backend]
D --> G
E --> G
F --> G
G --> H[Alert on Threshold]
G --> I[Dashboard Visualization]
H --> J[Slack/PagerDuty]
I --> K[Grafana]
Automated Health Checks
// health-monitor.ts
export interface HealthCheck {
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
responseTime: number;
message?: string;
metrics?: Record<string, number>;
}
export class TestEnvironmentMonitor {
async checkHealth(): Promise<HealthCheck[]> {
return Promise.all([this.checkDatabase(), this.checkRedis(), this.checkAPI(), this.checkBrowser()]);
}
private async checkDatabase(): Promise<HealthCheck> {
const start = Date.now();
try {
await db.raw('SELECT 1');
const responseTime = Date.now() - start;
const activeConnections = await db.raw('SELECT count(*) FROM pg_stat_activity');
return {
name: 'database',
status: responseTime < 100 ? 'healthy' : 'degraded',
responseTime,
metrics: {
activeConnections: activeConnections.rows[0].count,
},
};
} catch (error) {
return {
name: 'database',
status: 'unhealthy',
responseTime: Date.now() - start,
message: error.message,
};
}
}
private async checkBrowser(): Promise<HealthCheck> {
const start = Date.now();
try {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('about:blank');
await browser.close();
return {
name: 'browser',
status: 'healthy',
responseTime: Date.now() - start,
};
} catch (error) {
Sentry.captureException(error, {
tags: { component: 'browser-health-check' },
});
return {
name: 'browser',
status: 'unhealthy',
responseTime: Date.now() - start,
message: error.message,
};
}
}
}
// Run health checks before test suite
beforeAll(async () => {
const monitor = new TestEnvironmentMonitor();
const health = await monitor.checkHealth();
const unhealthy = health.filter((h) => h.status === 'unhealthy');
if (unhealthy.length > 0) {
console.error('Test environment health check failed:', unhealthy);
// Send alert
await Sentry.captureMessage('Test environment unhealthy', {
level: 'error',
extra: { healthChecks: health },
});
throw new Error('Test environment not healthy');
}
});
Best Practices for QA Observability
1. Log Structured Data
Always use structured logging with consistent fields:
logger.info('Test completed', {
testId: '123',
testName: 'checkout-flow',
duration: 5432,
status: 'passed',
retries: 0,
environment: 'staging',
tags: ['e2e', 'critical-path'],
});
2. Set Appropriate Sample Rates
Don't send 100% of events in production�it's expensive and noisy:
const sampleRates = {
production: {
traces: 0.1, // 10% of traces
errors: 1.0, // 100% of errors
},
staging: {
traces: 0.5, // 50% of traces
errors: 1.0,
},
test: {
traces: 1.0, // 100% - we want full visibility
errors: 1.0,
},
};
3. Correlate Events with Context
Always add correlation IDs to track requests across systems:
const correlationId = generateId();
Sentry.configureScope((scope) => {
scope.setTag('correlation_id', correlationId);
scope.setTag('test_run_id', testRunId);
scope.setUser({ id: testUserId });
});
4. Alert on Actionable Metrics
Configure alerts for metrics you can act on:
- Test failure rate > 5%
- Flaky test rate > 2%
- Test duration increase > 20%
- Error rate increase > 10%
Observability Maturity Model
graph LR
A[Level 1: No Observability] --> B[Level 2: Basic Logging]
B --> C[Level 3: Centralized Logging]
C --> D[Level 4: Metrics + Traces]
D --> E[Level 5: Full Observability]
style A fill:#ffcccc
style B fill:#ffffcc
style C fill:#ccffcc
style D fill:#ccffff
style E fill:#ccccff
Level 1: No Observability
- Tests pass/fail with no context
- Manual debugging through test reruns
- No production monitoring
Level 2: Basic Logging
- Console.log in tests
- Basic test output
- Inconsistent format
Level3: Centralized Logging
- Structured logging
- Log aggregation (ELK, Datadog)
- Search and filter capabilities
Level 4: Metrics + Traces
- Performance metrics collected
- Distributed tracing implemented
- Custom dashboards
Level 5: Full Observability
- All three pillars integrated
- Automated alerting
- Predictive analytics
- AIOps integration
Real-World Observability Success Story
The Problem
A fintech company running 2,500 E2E tests noticed:
- 15% of tests were flaky
- Average debug time: 2 hours per failure
- No visibility into production issues until customer reports
The Solution
Implemented comprehensive observability:
-
Sentry Integration
- Captured all test and production errors
- Added custom context (user journey, app state)
- Set up intelligent alerting
-
Custom Metrics Dashboard
- Test execution trends
- Flaky test identification
- Performance regression detection
-
Distributed Tracing
- End-to-end request tracking
- Bottleneck identification
- Database query optimization
The Results
| Metric | Before | After | Improvement |
|---|---|---|---|
| Flaky Tests | 15% | 2% | 87% reduction |
| Debug Time | 2 hours | 15 minutes | 88% faster |
| Production Issues Caught in QA | 60% | 95% | 58% increase |
| Mean Time to Resolution (MTTR) | 4 hours | 30 minutes | 88% faster |
| Customer-Reported Bugs | 25/month | 3/month | 88% reduction |
ROI: $240,000/year in reduced debugging costs and prevented outages.
Conclusion: From Testing to Test Intelligence
Monitoring and observability transform QA from reactive ("tests failed, now what?") to proactive ("I can see exactly what's happening and why").
Key takeaways:
- Implement the observability triad (logs, metrics, traces) for complete visibility
- Integrate error tracking (Sentry) to catch issues before users report them
- Build custom dashboards to visualize trends and patterns
- Monitor test environment health to prevent false failures
- Use structured logging and correlation IDs for debugging
- Progress through maturity levels systematically
The goal isn't just to know when something breaks�it's to understand why it broke, how often it breaks, and how to prevent it from breaking again.
Your tests should do more than verify behavior�they should illuminate it.
Ready to level up your QA observability? Start with ScanlyApp's monitoring and test intelligence features and catch issues before they reach production.
