Skip to main content

sys.monitoring

sys.monitoring (PEP 669) is the low-overhead instrumentation API. Tools register callbacks for events (call, line, branch, exception, ...); the runtime fires them by swapping the relevant bytecodes to INSTRUMENTED_* variants and stashing per-event state in side tables on the code object. Cold code pays nothing; hot code with no registered events pays only the cost of the event check (typically a single load and branch).

Where the code lives

FileRole
Python/instrumentation.cThe whole subsystem.
Include/internal/pycore_instruments.hEvent ids, per-code instrumentation data.
Include/cpython/monitoring.hPublic PyMonitoring_* entry points (C extensions).
Lib/sys.py, Python/sysmodule.cThe sys.monitoring Python API.
Python/bytecodes.cINSTRUMENTED_* opcode bodies and DISPATCH hooks.

Events

PEP 669 defines a fixed set of events:

PY_START, PY_RESUME, PY_RETURN, PY_YIELD, PY_THROW, PY_UNWIND
CALL, C_RETURN, C_RAISE
RAISE, RERAISE, EXCEPTION_HANDLED, STOP_ITERATION
LINE, INSTRUCTION, JUMP, BRANCH_LEFT, BRANCH_RIGHT

Each event has a numeric id. Tools register a callback per (tool_id, event_id) pair. Up to six tools can be active at once (debuggers, coverage, profilers, ...); each gets its own ids so they do not collide.

Tools and event sets

/* Include/internal/pycore_instruments.h _PyMonitoringEventSet */
typedef uint32_t _PyMonitoringEventSet;

/* Tools registered with the runtime */
typedef struct {
PyObject *callbacks[PY_MONITORING_EVENTS];
} _Py_GlobalMonitors;

The interpreter keeps three layers of event sets:

  • Global. Events enabled for everything.
  • Per code object. Events enabled for a specific function.
  • Local (per-instruction). Events enabled for one bytecode offset (for LINE and INSTRUCTION, which need finer control).

The effective set at an instruction is the union of the three; the instrumentation pass uses it to decide which opcodes to swap.

The INSTRUMENTED opcode mirror

Every event-emitting opcode has an INSTRUMENTED_* sibling generated from the same bytecodes.c DSL. Examples:

CALL -> INSTRUMENTED_CALL
RETURN_VALUE -> INSTRUMENTED_RETURN_VALUE
RESUME -> INSTRUMENTED_RESUME
JUMP_BACKWARD -> INSTRUMENTED_JUMP_BACKWARD
LOAD_SUPER_ATTR -> INSTRUMENTED_LOAD_SUPER_ATTR
END_FOR -> INSTRUMENTED_END_FOR

The body of an INSTRUMENTED_X does what X does and, before or after, calls into the instrumentation dispatcher to fire any registered callbacks. When monitoring is enabled for an event on a code object, the runtime rewrites the opcode byte from X to INSTRUMENTED_X in co_code; when it is disabled, it flips back. The flip is in place; no recompilation.

For LINE and INSTRUCTION, where the event fires at every instruction, the runtime inserts a single INSTRUMENTED_LINE / INSTRUMENTED_INSTRUCTION at the relevant offsets; the opcode body checks the per-instruction tooling array and fires only if the local event is enabled.

Side tables on the code object

Instrumentation does not change co_code shape. it changes the opcode bytes plus a few side tables:

/* Include/internal/pycore_code.h _PyCoMonitoringData */
typedef struct _PyCoMonitoringData {
_PyMonitoringEventSet local_events;
uint8_t *per_instruction_opcodes; /* original opcodes before flip */
uint8_t *per_instruction_tools; /* bitmask: which tools want LINE here */
/* ... */
} _PyCoMonitoringData;

co->_co_monitoring is NULL for uninstrumented code (the common case, no overhead). When monitoring activates on a code object, the runtime allocates the struct lazily.

  • per_instruction_opcodes remembers the pre-flip opcode so the runtime can restore on deactivation.
  • per_instruction_tools is a per-offset bitmask: bit i set means tool i wants LINE (or INSTRUCTION) at this offset. The INSTRUMENTED_LINE body checks the bitmask.

Firing a callback

When INSTRUMENTED_CALL fires the CALL event, the dispatcher walks the registered tools in id order and calls each one whose event bit is set in local_events:

/* Python/instrumentation.c (sketch) */
for (int tool = 0; tool < PY_MONITORING_TOOL_IDS; tool++) {
if (!(local_events & (1 << tool))) continue;
PyObject *cb = tstate->interp->monitors.tools[tool].callbacks[event];
if (cb == NULL) continue;
PyObject *res = PyObject_CallOneArg(cb, args);
if (res == DISABLE) {
/* turn this event off at this site permanently */
}
}

A callback can return the sentinel sys.monitoring.DISABLE to turn its event off at this code location for the rest of the process; the rewrite logic flips the offending bytecodes back to their uninstrumented form. This is how a coverage tool can mark a line as covered and never pay again.

Interaction with the specializer

Specialised opcodes can be instrumented. The flow:

  • The site starts generic.
  • The specializer rewrites it.
  • Monitoring is enabled; the rewrite flips the specialised opcode to INSTRUMENTED_<specialised>.
  • If monitoring is disabled, the byte flips back to the specialised opcode.

The cases generator emits INSTRUMENTED_* variants for every opcode that needs them, including the specialised ones; the metadata header records the mapping.

Interaction with Tier-2

Tier-2 cannot project through INSTRUMENTED_* opcodes. an instrumented site forces the trace projector to stop. This means heavily instrumented code stays in Tier-1; uninstrumented loops with the same shape still get traced.

Legacy tracing

sys.settrace and sys.setprofile still work; they are implemented on top of the same machinery. The legacy API is thinner: one callback, all events, no DISABLE. The new API should be preferred for new tooling because it can be selective.

CPython 3.14 changes

  • BRANCH_LEFT and BRANCH_RIGHT events (replacing the earlier BRANCH event) distinguish which side of a conditional jump was taken, which matters for branch coverage tools.
  • STOP_ITERATION event added so coverage tools can see generator and iterator termination without instrumenting every for.
  • Per-instruction events were rationalised; the INSTRUCTION event now fires after the instruction, not before, which lines up better with how debuggers expect step semantics.

PEP touchpoints

  • PEP 669. Low-impact monitoring for CPython.
  • PEP 626. Location table; LINE events depend on it.

Reference

  • Python/instrumentation.c, Include/internal/pycore_instruments.h, Include/cpython/monitoring.h.
  • PEP 669. Low impact monitoring.
  • Doc/library/sys.monitoring.rst.