Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

AioCop Logo

Non-intrusive monitoring for Python asyncio.
Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

PyPI versionPython versionsLicenseDocumentation

Features

  • Production-Safe & Low Overhead: Leverages Python's sys.audit hooks for minimal runtime overhead, making it safe for production use
  • Works with asyncio and uvloop: Compatible with both standard asyncio and uvloop event loops out of the box
  • Blocking I/O Detection: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code
  • Stack Trace Capture: Captures full stack traces to pinpoint exactly where blocking calls originate
  • CPU Stack Sampling: A lightweight watchdog samples the loop thread during CPU-bound slices, so cpu_blocking events carry stack attribution too — on by default, no profiler needed
  • Severity Scoring: Assigns severity scores to blocking events to help prioritize fixes
  • Callback-based Events: Register callbacks to handle slow task events however you need (logging, metrics, alerts)
  • Dynamic Controls: Enable/disable monitoring at runtime, useful for gradual rollout or debugging sessions
  • Exception Raising: Optionally raise exceptions on high-severity blocking I/O for strict enforcement during development

How It Works

aiocop architecture diagram

aiocop wraps the event loop's scheduling methods (call_soon, call_later, etc.) and uses Python's sys.audit hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like open(), the audit event is captured along with the full stack trace—letting you know exactly where the problem is.

Why aiocop?

aiocop was built to solve specific production constraints that existing approaches didn't quite fit.

vs. Heavy Monkey-Patching (e.g., blockbuster): Many excellent tools rely on extensive monkey-patching of standard library logic to detect blocking calls. While effective, this approach can sometimes conflict with other libraries that instrument code (like APMs). aiocop prioritizes native sys.audit hooks, using minimal wrappers only where necessary to emit audit events. This significantly reduces the risk of conflicts with other instrumentation tools.

vs. asyncio Debug Mode: Python's built-in debug mode is invaluable during development. However, it can be heavy on logs and performance, making it impractical to leave on in high-traffic production environments. aiocop is designed to be "always-on" safe.

FeatureHeavy Monkey-Patching Toolsasyncio Debug Modeaiocop
Detection MethodExtensive WrappersEvent Loop Instrumentationsys.audit Hooks + Minimal Wrappers
Interference RiskMedium (can conflict with APMs)NoneNone
Production OverheadLow-MediumHighVery Low (~13μs/task)
Stack TracesYesNo (timing only)Yes
Runtime ControlVariesFlag at startupDynamic on/off
uvloop SupportVariesNoYes

Performance

aiocop adds approximately 13 microseconds of overhead per async task:

ScenarioOverheadImpact on 50ms Request
Pure async (no blocking I/O)~1 us0.002%
Light blocking (os.stat)~14 us0.03%
Moderate blocking (file read)~12 us0.02%
Realistic HTTP handler~22 us0.04%

For typical web applications, this means less than 0.05% overhead.

Run the benchmark yourself: python benchmarks/run_benchmark.py

Installation

pip install aiocop

Quick Start

Copy this into a file and run it - no dependencies needed besides aiocop:

# test_aiocop.pyimportasyncioimportaiocopdefon_slow_task(event):
print(f"SLOW TASK DETECTED: {event.elapsed_ms:.1f}ms")
print(f" Severity: {event.severity_level}")
forevtinevent.blocking_events:
print(f" - {evt['event']} at {evt['entry_point']}")
asyncdefblocking_task():
# This synchronous open() will block the loop - aiocop will catch it!withopen("/dev/null", "w") asf:
f.write("data")
awaitasyncio.sleep(0.1)
asyncdefmain():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=10, on_slow_task=on_slow_task)
aiocop.activate()
awaitasyncio.gather(blocking_task(), blocking_task())
if__name__=="__main__":
asyncio.run(main())
python test_aiocop.py
# Output:# SLOW TASK DETECTED: 102.3ms# Severity: medium# - open(/dev/null, w) at test_aiocop.py:14:blocking_task

Usage with ASGI (FastAPI, Starlette, etc.)

# In your ASGI application setup (e.g., main.py or asgi.py)fromcontextlibimportasynccontextmanagerimportaiocopdefsetup_monitoring() ->None:
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection(trace_depth=20)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_monitoring)
deflog_to_monitoring(event: aiocop.SlowTaskEvent) ->None:
# Send to your monitoring system (Datadog, Prometheus, etc.)ifevent.exceeded_threshold:
metrics.increment("async.slow_task", tags={
"severity": event.severity_level,
"reason": event.reason,
})
metrics.gauge("async.slow_task.elapsed_ms", event.elapsed_ms)
# Call setup early in your application lifecyclesetup_monitoring()
# Activate after startup (e.g., in a lifespan handler)@asynccontextmanagerasyncdeflifespan(app):
aiocop.activate() # Start monitoring after startupyieldaiocop.deactivate()

Dynamic Controls

Enable/Disable Monitoring at Runtime

# Pause monitoringaiocop.deactivate()
# Resume monitoringaiocop.activate()
# Check if monitoring is activeifaiocop.is_monitoring_active():
print("Monitoring is running")

Raise Exceptions on High Severity Blocking I/O

Useful during development and testing to catch blocking calls immediately:

# Enable globally for current contextaiocop.enable_raise_on_violations()
# Disableaiocop.disable_raise_on_violations()
# Or use as a context managerwithaiocop.raise_on_violations():
awaitsome_operation() # Will raise HighSeverityBlockingIoException if blocking

CI/CD Integration - Fail Tests on Blocking I/O

Use aiocop in your integration tests to prevent blocking code from being merged:

# conftest.pyimportpytestimportaiocop@pytest.fixture(scope="session", autouse=True)defsetup_aiocop():
aiocop.patch_audit_functions()
aiocop.start_blocking_io_detection()
aiocop.detect_slow_tasks(threshold_ms=50)
aiocop.activate()
# test_views.py@pytest.mark.asyncioasyncdeftest_my_async_endpoint(client):
# Setup code can have blocking I/O (fixtures, test data, etc.)# Only the view execution is wrapped - this is what we care aboutwithaiocop.raise_on_violations():
response=awaitclient.get("/api/endpoint")
# Assertions can have blocking I/O too (DB checks, etc.)assertresponse.status_code==200

We wrap only the async view (not the entire test) because test setup/teardown often has legitimate blocking code. See Integrations for complete examples.

CPU Stack Sampling

Blocking I/O gets stack attribution from audit events, but a cpu_blocking slice is just Python executing — nothing auditable fires. CPU stack sampling closes that gap: a watchdog daemon thread samples the loop thread's stack while a monitored callback has been running longer than an arming delay, and attaches the aggregated samples to the resulting SlowTaskEvent as cpu_stack_samples.

On by default.detect_slow_tasks() starts it automatically. The arming delay defaults to half the slow-task threshold (and follows it if the threshold changes), so any slice that goes on to violate has been under sampling since its midpoint.

# Disable it:aiocop.detect_slow_tasks(threshold_ms=30, cpu_sampling=False)
# Customize it — call BEFORE detect_slow_tasks() (the auto-start then steps aside):aiocop.start_cpu_sampling(interval_ms=5, arm_after_ms=10)
aiocop.detect_slow_tasks(threshold_ms=30)

Reading the result in a callback:

defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
ifevent.reason=="cpu_blocking"andevent.cpu_stack_samples:
top=event.cpu_stack_samples[0]
print(f"CPU-bound slice ({event.elapsed_ms:.1f}ms), hottest stack "f"({top['count']} samples): {top['trace']}")

Overhead: the hot path adds two module-global stores per monitored callback (~0.1µs); the watchdog costs well under 1% of a core when idle and captures at most max_samples_per_slice (default 32) stacks per slice — and only for slices that are already frozen. Sampling works on any thread the loop runs on and survives fork() (gunicorn --preload workers restart the watchdog automatically).

Known limitation: a single long-running C call that never releases the GIL starves the watchdog — few samples for a long slice is itself a signal that one C-level call dominated it.

Context Providers

Context providers allow you to capture external context (like tracing spans, request IDs, etc.) that will be passed to your callbacks. The context is captured within the asyncio task's context, ensuring proper propagation of contextvars.

Basic Usage

fromtypingimportAnydefmy_context_provider() ->dict[str, Any]:
return {
"request_id": get_current_request_id(),
"user_id": get_current_user_id(),
}
aiocop.register_context_provider(my_context_provider)
defon_slow_task(event: aiocop.SlowTaskEvent) ->None:
request_id=event.context.get("request_id")
print(f"Slow task in request {request_id}: {event.elapsed_ms}ms")

Integration with Datadog

fromddtraceimporttracerfromtypingimportAnydefdatadog_context_provider() ->dict[str, Any]:
return {"datadog_span": tracer.current_span()}
aiocop.register_context_provider(datadog_context_provider)
deflog_to_datadog(event: aiocop.SlowTaskEvent) ->None:
ifevent.exceeded_thresholdisFalse:
returnspan=event.context.get("datadog_span")
ifspanisNone:
returnspan.set_tag("slow_task.detected", True)
span.set_metric("slow_task.elapsed_ms", event.elapsed_ms)
span.set_metric("slow_task.severity_score", event.severity_score)
span.set_tag("slow_task.severity_level", event.severity_level)
span.set_tag("slow_task.reason", event.reason)
aiocop.detect_slow_tasks(threshold_ms=30, on_slow_task=log_to_datadog)

Why Context Providers?

When aiocop detects a slow task, the callback is invoked after the task completes. By that time, the original context (like the active tracing span) might no longer be accessible via standard context lookups.

Context providers solve this by capturing the context at the start of each task execution, within the task's own contextvars context. This ensures that:

  1. Tracing spans are captured before they're closed
  2. Request-scoped data is available to callbacks
  3. Any contextvar-based state is properly preserved

Managing Context Providers

# Register a provideraiocop.register_context_provider(my_provider)
# Unregister a specific provideraiocop.unregister_context_provider(my_provider)
# Clear all providersaiocop.clear_context_providers()

Context providers are completely optional. If none are registered, event.context will simply be an empty dict.

Event Types

SlowTaskEvent

Emitted when either:

  • Blocking I/O is detected (reason="io_blocking") - regardless of whether the task exceeded the threshold
  • Task exceeds threshold but no blocking I/O detected (reason="cpu_blocking") - indicates CPU-bound blocking
@dataclass(frozen=True)classSlowTaskEvent:
elapsed_ms: float# How long the task tookthreshold_ms: float# Configured thresholdexceeded_threshold: bool# True if elapsed > thresholdseverity_score: int# Aggregate severity (sum of event weights), 0 for cpu_blockingseverity_level: str# "low", "medium", or "high"reason: str# "io_blocking" or "cpu_blocking"blocking_events: list[BlockingEventInfo] # List of detected events (empty for cpu_blocking)context: dict[str, Any] # Custom context from context providers (default: {})cpu_stack_samples: list[CpuStackSample] # Aggregated loop-thread stack samples (default: [])

BlockingEventInfo

Information about each blocking event:

classBlockingEventInfo(TypedDict):
event: str# e.g., "open(/path/to/file)"trace: str# Stack traceentry_point: str# First frame in the traceseverity: int# Weight of this event

CpuStackSample

Aggregated stack sample captured during a CPU-bound slice (see CPU Stack Sampling):

classCpuStackSample(TypedDict):
trace: str# Stack trace ("frame <- frame <- ...")entry_point: str# First frame in the tracecount: int# How many samples showed this exact stack

Samples are ordered by count descending — the first entry is where the slice most likely spent its CPU time.

Severity Weights

Events are classified by severity:

WeightValueExamples
WEIGHT_HEAVY50socket.connect, subprocess.Popen, time.sleep, DNS lookups
WEIGHT_MODERATE10open(), file mutations, os.listdir
WEIGHT_LIGHT1os.stat, fcntl.flock, os.kill
WEIGHT_TRIVIAL0os.getcwd, os.path.abspath

Severity levels are determined by aggregate score:

  • high: score >= 50
  • medium: score >= 10
  • low: score < 10

API Reference

Setup Functions

  • patch_audit_functions() - Patches stdlib functions to emit audit events
  • start_blocking_io_detection(trace_depth=20) - Registers the audit hook
  • detect_slow_tasks(threshold_ms=30, on_slow_task=None, cpu_sampling=True) - Patches the event loop; starts CPU stack sampling unless disabled
  • start_cpu_sampling(interval_ms=10, arm_after_ms=None, idle_interval_ms=None, max_samples_per_slice=32, trace_depth=20) - Start (or pre-configure) CPU stack sampling
  • is_cpu_sampling_started() - Whether the sampling watchdog is running
  • activate() / deactivate() - Control monitoring at runtime

Callback Management

  • register_slow_task_callback(callback) - Add a callback
  • unregister_slow_task_callback(callback) - Remove a callback
  • clear_slow_task_callbacks() - Remove all callbacks

Context Provider Management

  • register_context_provider(provider) - Add a context provider
  • unregister_context_provider(provider) - Remove a context provider
  • clear_context_providers() - Remove all context providers

Raise-on-Violations Controls

  • enable_raise_on_violations() - Enable for current context
  • disable_raise_on_violations() - Disable for current context
  • is_raise_on_violations_enabled() - Check current state
  • raise_on_violations() - Context manager

Utility Functions

  • calculate_io_severity_score(events) - Calculate severity from events
  • get_severity_level_from_score(score) - Get "low"/"medium"/"high"
  • format_blocking_event(raw_event) - Format a raw event
  • get_blocking_events_dict() - Get all monitored events with weights
  • get_patched_functions() - Get list of patched functions

About

Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop.

Resources

Code of conduct

Contributing

Stars

20 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages