Source of truth for integrating and using SMonitor in this library.
Metadata
- Source repository:
smonitor - Source document:
standards/SMONITOR_GUIDE.md - Source version:
smonitor@0.22.0 - Last synced: 2026-10-22
SMonitor is the diagnostics layer for the UIBCDF ecosystem. It centralizes warnings, errors, and developer signals so that user messages are consistent, actionable, and traceable across libraries.
SMonitor is not just a logging wrapper; it is a Signal Orchestrator that decouples event emission from message presentation.
- Consistency: Users see clear, helpful messages formatted as cards.
- Traceability: Developers can see the "Breadcrumb" trail across libraries.
- AI-Ready: Agents can parse structured events via the
agentprofile.
Treat this guide in three layers:
- Mandatory
- required for a correct SMonitor integration in any sibling library.
- Recommended
- expected for modern QA/support readiness.
- Advanced / CI-support
- valuable once the mandatory and recommended layers are already in place.
If time is constrained, implement in that order.
At minimum, a sibling library should have:
_smonitor.pyplus private catalog/meta files.ensure_configured(PACKAGE_ROOT)in package initialization (first-use bootstrap only), orregister_provider(PACKAGE_ROOT, provider="mylib")for declaration-only registration. Both preserve an already configured application policy. Only explicituse_provider_policy=Trueopts into reconfiguration. Applications requiring metadata-only capture should entersmonitor.diagnostic_scope(safe_extra={...})before provider calls; providers inspectget_capture_policy()before constructing native-error payloads. Seedocs/content/user/library-integrators/scoped-capture.mdfor permissions, bounded primitives, task/thread propagation and ownership limits.- catalog-driven emission via
DiagnosticBundle/ catalog exceptions and warnings. @signalon public orchestration entry points.context_extra(...)for repeated structured diagnostic fields.- the verification tests of section 7, plus one smoke test covering bundle export.
The application-policy-preserving setup, declaration-only provider registration
and scoped diagnostic capture described here are publicly delivered in
SMonitor >=0.19.0, under uibcdf/smonitor#35,
uibcdf/smonitor#37 and uibcdf/smonitor#38. Select that
capability floor when relying on these promises. An earlier supported provider
retains its original released behavior; an existing lower dependency floor does
not establish the same registration or scoped-capture guarantees.
This capability boundary does not require every consumer to upgrade now. Detailed diagnostics remain available, and restrictive capture is an explicit application/consumer choice. Consumers own their chosen dependency floor, integration and receiving tests. A scope controls the provider's documented capture paths; it is not a global guarantee over arbitrary third-party code.
SMonitor >=0.20.0 adds opt-in bootstrap_policy=False to
DiagnosticBundle, emit_from_catalog and smonitor.emit. It registers provider
declarations and emits through application-owned handlers without installing
fallback policy or warning/logging hooks. The default True preserves the
existing first-use behavior. A consumer relying on this option must require the
0.20.0 capability floor and qualify the installed provider separately from
source integration (uibcdf/smonitor#48, uibcdf/smonitor#50).
SMonitor >=0.21.0 adds optional audience-specific safe metadata templates
(metadata_messages, metadata_hints; section 1.3) and bounded nonemitting
identity bundles (section 5.3.1). These capabilities have separate capture and
export limits; neither makes arbitrary application data safe to share.
SMonitor >=0.22.0 adds opt-in pytest-check and pytest-baseline commands
over Pytest Receptor's canonical artifact (section 5.5). The observational
bridge itself is available from >=0.18.0. Selecting a new QA command requires
its own capability floor; receiving a guide copy does not activate it or raise
a consumer's runtime dependency floor.
If the library package is named mylib, the following files must exist:
mylib/_smonitor.py: Runtime configuration and message templates (CODES).
Inside the package, not at the repository root. Configuration discovery walks
upward, so a file at the repository root is found in a development checkout and
works there — and then is not packaged into the wheel, so it is simply absent for
everyone who installed your library. The failure is silent: diagnostics fall back
to defaults, every catalog code resolves against no template, and messages come
out empty. Every library in the ecosystem places it at mylib/_smonitor.py, and
PACKAGE_ROOT in mylib/_private/smonitor/catalog.py points at that directory.
Example _smonitor.py:
PROFILE = "user"
SMONITOR = {
"level": "WARNING",
"trace_depth": 3,
"capture_warnings": True,
"capture_logging": True,
"theme": "plain",
"silence": ["pint", "networkx"], # Noisy loggers to ignore
}The SMONITOR block accepts the keyword arguments of smonitor.configure(...);
smonitor --validate-config lists any it does not recognise. An unrecognised key
is reported and then ignored rather than raising, because this file is loaded
during your package's import and a typo must not take the library down —
strict_config = True turns those reports into an error when you want the
stricter behaviour.
mylib/_private/smonitor/catalog.py: Catalog entries (meta-data about each signal).mylib/_private/smonitor/meta.py: Project metadata (URLs for documentation and issues).mylib/_private/smonitor/__init__.py: ExportsCATALOG,META, andPACKAGE_ROOT.
CODES and SIGNALS must be resolved from exactly one authoritative place.
Recommended pattern:
- define
CATALOG,CODES, andSIGNALSinmylib/_private/smonitor/catalog.py; - in
_smonitor.py, import them frommylib._private.smonitor.catalog.
This avoids drift where emitted catalog codes exist but template messages are missing at runtime.
A CODES entry may carry a message and a hint per profile:
CODES = {
"MYLIB-W010": {
"title": "Selection ambiguous",
"user_message": "Selection '{selection}' is ambiguous.",
"user_hint": "Use a more specific selector, for example '{example}'.",
"dev_message": "Selection parser ambiguity on '{selection}'.",
"dev_hint": "Review selector normalization.",
}
}Each profile reads its own field first — user reads user_message, qa reads
qa_message, and so on — and falls back through the nearest audience to the
user_* field when its own is absent. A generic message sits in the middle of
every message chain. An entry that defines any message field renders in every
profile; you are never required to write all four.
Write the variants that genuinely differ. MolSysMT writes all four for 49 codes and only 2 of them repeat the same sentence, which is the feature working as intended: the end user is told a probe did not succeed, the developer is given the exception type and its message. Where one sentence serves every audience, write it once.
Before 0.14.0 there was no fallback, and an entry defining user_message
alone rendered an empty message under dev, qa, agent and debug. If
your library targets an older SMonitor, keep writing every field you rely on.
Applications can restrict capture with diagnostic_scope() or
METADATA_ONLY. A provider can offer useful catalog wording using only the
bounded primitive facts explicitly approved by the caller:
# Provider catalog, imported by its _smonitor.py configuration.
CODES = {
"MYLIB-REF": {
"user_message": "{count} missing references.",
"metadata_messages": {"user": "Restore {count} references."},
"metadata_hints": {
"user": "Restore retained bytes.",
"dev": "Inspect local index binding.",
},
"metadata_hint": "Review retained references.",
}
}After the provider's normal registration, the application can resolve it under an explicit capture scope:
import smonitor
with smonitor.diagnostic_scope(safe_extra={"count": 2}):
message, hint = smonitor.resolve(code="MYLIB-REF", profile="user")The maps accept user, dev, qa, and agent; debug uses dev.
An audience entry takes precedence over the shared metadata_message or
metadata_hint. Missing audience entries fall back to the shared metadata
field; messages can then use the existing ordinary message fallback when its
fields are available. Ordinary profile hints are never assumed safe. Missing
approved facts produce a fixed description or no hint.
Provider registration rejects unsupported audiences and unsafe map templates:
attribute/index traversal, conversions, format specifications, and templates
longer than 1024 characters. Approved facts remain the caller's responsibility;
primitive type and size checks do not classify credentials or scientific data.
resolve() neither emits an event nor configures application policy.
See the scoped capture contract for capture permissions, task/thread propagation, and the distinction between restricting SMonitor collection and formatting already performed by caller code.
Level: Mandatory
In your library's __init__.py, ensure SMonitor is configured on import. This activates the "System Nervous System":
from smonitor.integrations import ensure_configured
from ._private.smonitor import PACKAGE_ROOT
ensure_configured(PACKAGE_ROOT)Level: Mandatory
All diagnostic output must be driven by the catalog. Never hardcode strings in the scientific logic.
Use the DiagnosticBundle to create consistent warn and warn_once helpers in your library's _private/smonitor/emitter.py:
# mylib/_private/smonitor/emitter.py
from smonitor.integrations import DiagnosticBundle
from . import CATALOG, META, PACKAGE_ROOT
bundle = DiagnosticBundle(CATALOG, META, PACKAGE_ROOT)
warn = bundle.warn
warn_once = bundle.warn_once
resolve = bundle.resolveAll custom exceptions must inherit from CatalogException (provided by smonitor.integrations). This ensures messages are automatically hydrated from the catalog.
# mylib/_private/smonitor/exceptions.py
from smonitor.integrations import CatalogException
from . import CATALOG, META
class MyLibException(CatalogException):
def __init__(self, message=None, **kwargs):
super().__init__(message, catalog=CATALOG, meta=META, **kwargs)
class ArgumentError(MyLibException):
catalog_key = "ArgumentError"
# ... logic to prepare extra dict ...Similarly, use CatalogWarning for warning classes:
# mylib/_private/smonitor/warnings.py
from smonitor.integrations import CatalogWarning
from .emitter import bundle
class MyLibWarning(CatalogWarning):
# ... setup catalog and meta ...Note: The raw emit_from_catalog function is still available but DiagnosticBundle is the preferred high-level interface.
A catalog template may interpolate its own placeholders. Pass typed fields in
extra and let SMonitor render them — do not pre-render the sentence and hand
it over as a string:
# Correct: the template owns the wording, the call site owns the data.
# CODES["MYLIB-W010"]["user_message"] = "Atom name '{atom_name}' is not recognized."
warn(UnknownAtomNameWarning(atom_name=atom_name))
# Avoid: the template can only say "{message}", and the structured field
# never reaches report(), fingerprints, or resource counters.
warn(UnknownAtomNameWarning(message=f"Atom name '{atom_name}' ..."))warn(instance) carries the instance's extra into the emitted event, so those
fields reach report(), events_by_fingerprint, and most_noisy_resources as
typed data. {message} remains available for string callers
(warn("some text", MyWarning)).
Catching code should read exc.code and exc.extra rather than parsing the
rendered English message.
class UnknownAtomNameWarning(CatalogWarning):
catalog_key = "UnknownAtomNameWarning"
def __init__(self, message=None, *, atom_name=None):
super().__init__(message, catalog=CATALOG, extra={"atom_name": atom_name})Python rebuilds a warning or exception as type(w)(*w.args). pickle does it,
copy.deepcopy does it, warnings.warn(text, category) does it, and pytest-xdist
does it when a warning crosses from a worker to the controller. A class that
names a domain field first therefore receives its own rendered sentence as that
field, and the template renders around its own output:
Atom name 'Atom name 'Ar' is not recognized.' is not recognized.
Putting message first makes that rebuild land where it belongs. Keeping the
fields keyword-only preserves what a per-field signature is for: a misspelled
field stays a TypeError instead of becoming an extra nobody reads and a
template rendered with holes in it.
Where a class needs to compute its message, render it in a classmethod and
hand the finished text to __init__, so the constructor still stores what it is
given rather than deriving it:
@classmethod
def for_atoms(cls, names):
joined = ", ".join(sorted(names))
rendered, _ = smonitor.resolve(code="MYLIB-W010", extra={"atom_name": joined})
return cls(rendered, atom_name=joined)One residue is not fixable this way: a hint whose template interpolates a field
cannot be re-rendered by a rebuilder that carries only args, since the field is
not there. That is a limitation of the transfer, not of the class, and it is
being addressed upstream in pytest-dev/pytest-xdist#1372.
Use warn(...) rather than warnings.warn(...). It does both jobs: it emits the
structured, catalog-backed event and raises the warning through Python's
warning machinery, so everything your users and your test suite already rely on
keeps working:
# Your users' filters apply as usual.
warnings.filterwarnings("ignore", category=UnknownAtomNameWarning)
warnings.simplefilter("error") # promotes it to an exception
# Your tests assert on it as usual.
with pytest.warns(UnknownAtomNameWarning, match="XXX"):
get_atom_type_from_atom_name("XXX")Calling warnings.warn(MyCatalogWarning(...)) directly instead is the mistake
this replaces. The warning still reaches SMonitor when capture_warnings is on,
but only by way of the py.warnings logger, which delivers it as Python's
formatted text — file path and source line included — with code=None,
source="py.warnings", no category and none of your structured fields. The
catalog entry is bypassed entirely, so the incident is invisible to
events_by_code, to fingerprint summaries and to any QA policy keyed on codes.
A filter that suppresses the warning for the user does not suppress the
SMonitor event: filters govern the console, not your telemetry. And when
simplefilter("error") promotes the warning to an exception, the event has
already been recorded before it is raised.
DiagnosticBundle.warn() and warn_once() count application frames for
stacklevel, skipping their own frames and @signal wrappers. With the default
stacklevel=2, a warning raised in a library function is attributed to its
caller even when that function has @signal. A plain warnings.warn(...)
inside an @signal function still counts the decorator frame: increase its
stacklevel by one for each @signal wrapper on that call path, or route a
catalogued diagnostic through DiagnosticBundle.warn().
Do not swallow diagnostics emission errors with except Exception: pass.
If emission fails in non-critical paths:
- fallback to a plain Python warning/log line;
- keep enough context (
caller, signal key, exception text) for debugging.
Silencing emission failures causes loss of traceability and empty/noisy diagnostics in downstream libraries.
Level: Mandatory
Sections 3.1 to 3.4 are the wiring. This one is the craft: given an error condition in your code, what do you actually write, and what does each choice buy you downstream.
| owned by | holds | |
|---|---|---|
code |
the catalog | the stable identity of this kind of incident |
| message | the catalog | the sentence, per profile |
| hint | the catalog | what to do about it, per profile |
extra |
the call site | the typed facts of this occurrence |
One rule follows from the table, and everything else in this section is a consequence of it:
Prose belongs to the catalog. Data belongs to the call site.
A caller misspells an argument. Written with prose at the call site:
# The call site renders both sentences and passes them as strings.
raise UnknownArgumentError(
f"{caller!r} does not accept the argument {name!r}.",
hint=f"Did you mean {guess!r}?",
)"MYLIB-E010": {"user_message": "{message}", "user_hint": "{hint}"}Written with data:
raise UnknownArgumentError.for_keyword(caller=caller, argname=name, guess=guess)"MYLIB-E010": {
"user_message": "'{caller}' does not accept the argument '{argname}'.",
"user_hint": "{guess} See {doc_url}.",
"dev_message": "Argument '{argname}' is not in the contract of '{caller}'.",
"dev_hint": "{guess} Extend the contract if legitimate.",
}The user reads the same sentence either way. What differs is everything after the console:
prose: extra = {"message": "'pkg.get' does not accept…", "hint": "Did you mean…"}
data: extra = {"caller": "pkg.get", "argname": "structur_indices",
"guess": "Did you mean 'structure_indices'?"}
In the first, report() receives two English sentences and one duplicate of the
message the event already carries. In the second it receives fields it can count,
group and compare. A template of "{message}" also means the catalog cannot
improve the user-facing wording at all: it can only echo what a call site
hardcoded, in one language, the same for every profile.
Some values are irreducibly a sentence — a did-you-mean, a remedy derived from something the user declared at runtime. Those travel as a declared field, with a template that is a bare placeholder:
"MYLIB-E011": {"user_hint": "{remedy}", "dev_hint": "{remedy} (contract: {caller})"}This is not the previous case wearing a disguise. Which codes accept a call-site
sentence is visible in the catalog, to a reviewer, instead of being discoverable
only by reading every raise site; the per-profile framing stays with the catalog;
and the value reaches report() and the bundle as its own field. What is not
allowed is a diagnostic that bypasses the catalog entirely.
- Namespace it with your library:
MYLIB-E010,MYLIB-W010. - One code per thing the user must do differently, not one per raise site.
Three call sites that all mean "this file is not readable" share a code and
differ in
extra; two conditions with different remedies need two codes even if they are raised on adjacent lines. - A published code is frozen. Its wording may improve; its meaning may not change. Retire a code rather than repurpose it — QA policies, dashboards and triage baselines are keyed on it.
| you write | you get |
|---|---|
code |
events_by_code, top_codes, and any CI or QA policy keyed on codes |
source |
events_by_source — which module the incident came from |
category |
events_by_category — the coarse grouping in reports and bundles |
typed extra |
most_noisy_resources, and the fingerprint below |
@signal (section 4) |
context.chain — the breadcrumb across library boundaries |
canonical extra keys |
the incident fingerprint, and everything built on it |
Skip the code and the incident is invisible to events_by_code and to every
policy keyed on it. Skip @signal on the orchestration path and the event says
where it was raised but not how the user got there.
A fingerprint is what makes "the same problem, again" recognisable — in
events_by_fingerprint, in recurrent_incidents, and in smonitor compare
between two runs. It is derived from code, source, exception_type, and only
these extra keys:
caller, form, requested_attribute, resource, provider, operation,
retry_attempt, retry_max, retry_exhausted, failure_class,
cause_exception_type, cause_code
Two consequences worth knowing before you choose key names, because neither is guessable:
- A fact outside that list does not split the incident. Two failures on the
same
resourcethat differ only in a field of your own naming group as one. That is usually what you want; if you need them apart, the distinguishing fact has to be one of the keys above. retry_attemptis on the list, so a retry loop that stamps it produces a different fingerprint per attempt. Fingerprints therefore do not collapse retries, and neither doesduplicate_policy, which keys on the fingerprint.warning_coalesce_window_sis what collapses them: it keys on code, source,resource,callerand message instead. Use coalescing for retry storms andduplicate_policyfor genuinely identical repeats.
Use context_extra(...) (section 5.2) to fill those keys, rather than spelling
them yourself: it is the list, in code, and a typo in a key name is a field that
silently stops taking part in any of this.
A fingerprint means something only for a coded event. With no code, the
derivation above leaves source alone, so every uncoded event from one module —
every logging call the bridge captures — collapses into a single bucket
whatever it says. Measured on a real suite: 219 events in one bucket, carrying
102 distinct messages.
top_fingerprints therefore labels each row with the code behind it, and
recurrent_incidents holds only the coded ones: a log line is not an incident,
and a list named for incidents should not report it as one. Nothing is hidden —
the uncoded buckets stay in top_fingerprints and in events_by_fingerprint.
The practical consequence for you is section 3.5's first rule, again from the other side: a diagnostic without a code is invisible to every aggregate this section describes. Not degraded — invisible.
Level: Recommended
To enable execution traceability (breadcrumbs), decorate all major API entry points and internal orchestration functions.
from smonitor import signal
@signal(tags=["topology"])
def get_atoms(molecular_system, selection="all"): ...Benefits:
- On error, SMonitor reports the full call chain:
[mylib.api_func] -> [otherlib.internal_logic] -> [ERROR]. - Performance telemetry can be enabled globally without changing the code.
Level: Recommended
Enforce structured data by defining required fields in _smonitor.py:
SIGNALS = {
"mylib.select": {
"extra_required": ["selection"],
}
}Missing fields will trigger warnings or errors in dev and qa profiles, ensuring diagnostic quality.
Level: Recommended
Recent pre-1.0 stabilization work added several profiling and machine-diagnostics capabilities that integrators should use deliberately:
@signal(..., extra_factory=...)can attach structured per-call context without emitting a separate warning or error event.report()now exposestimings_by_tagin addition to timings by function and module.slow_signal_msandslow_signal_levelenable opt-in slow-call events (SMONITOR-SIGNAL-SLOW) for developer and QA workflows.
Recommended usage:
from smonitor import signal
@signal(
tags=["api", "selection"],
extra_factory=lambda args, kwargs: {"selection": kwargs.get("selection")},
)
def get_atoms(molecular_system, selection="all"): ...These features are intended for observability and QA; they should remain opt-in and must not flood end-user output by default.
Level: Recommended
For library-generated diagnostics, prefer smonitor.integrations.context_extra(...) when building repeated extra payloads.
from smonitor.integrations import context_extra, emit_from_catalog
emit_from_catalog(
CATALOG["warnings"]["DownloadWarning"],
extra=context_extra(
caller="mylib.form.file_pdb.download",
resource="181l.pdb",
provider="RCSB",
operation="download",
extra={"attempt": 2, "retries": 5},
),
)Use this helper for stable shared keys such as caller, form, requested_attribute, resource, provider, and operation.
Current canonical additive fields also include:
- retry metadata:
retry_attempt,retry_max,retry_exhausted,retry_delay_s; - causal metadata:
failure_class,last_failure_reason,cause_exception_type,cause_code,causal_chain; - decision metadata:
incident_kind,severity,priority,diagnostic_confidence,recommended_action,next_step,retryable,support_needed; - structured
evidencefor compactexpected/observed/resource/operationfacts.
Compact modern example:
from smonitor.integrations import context_extra
extra = context_extra(
caller="mylib.io.download_structure",
resource="181l.pdb",
provider="RCSB",
operation="download",
retry_attempt=2,
retry_max=5,
retry_exhausted=False,
failure_class="network",
last_failure_reason="timeout",
incident_kind="network",
recommended_action="retry",
next_step="check-network",
evidence={"expected": "download ok", "observed": "timeout"},
)Level: Advanced / CI-support
SMonitor now exposes QA-oriented summaries beyond raw event streams:
- events carry stable
fingerprint,run_id,session_id, and optionalcorrelation_id. - events also carry a stable additive
human_summaryblock for concise human-facing handoff. report()includesevents_by_code,events_by_category,events_by_fingerprint,slow_signals_recent, andcoalesced_warnings.report()also exposes operational triage sections such astop_codes,top_sources,top_fingerprints,most_noisy_resources,most_expensive_entries,blocking_incidents,actionable_incidents, andrecurrent_incidents.- bundle exports mirror those summaries under
triage. - bundle exports also carry a
runtimeblock and can be compared locally withsmonitor compare. JsonHandlerincludes anormalizedpayload section with stable machine-oriented fields for cross-library ingestion, including retry/causal metadata, decision metadata, structuredevidence, and mirroredhuman_summary.
These additions should be treated as the preferred source for automated QA summaries before scanning raw event buffers.
Minimal CI/support flow:
- run the representative workflow;
- export a local bundle inside the same diagnostic-producing process;
- inspect
triagefirst, not raw events; - compare against a previous bundle when asking “what changed?”;
- only fall back to raw event streams when the summaries are insufficient.
The in-memory event buffer belongs to its process. Running smonitor export
after a separate pytest process exits cannot recover that process's retained
events. For pytest evidence, use the canonical artifact in section 5.5.
When a consumer only needs a narrow snapshot of retained identities, use:
import smonitor
bundle = smonitor.collect_identity_bundle(max_events=20, allowed_codes={"MYLIB-W010"})
smonitor.export_identity_bundle(
"support-identities.json",
max_events=20,
allowed_codes={"MYLIB-W010"},
)The single-file smonitor.identity-bundle@1 profile exports fixed provenance,
coverage/omission metadata, and at most 100 retained events. Each event contains
only a validated level, a 16-character fingerprint, and an explicitly approved
code (otherwise None). The code allowlist accepts at most 128 uppercase code
identifiers, each at most 64 characters. The consumer owns their approval.
This operation does not emit, invoke handlers, load project configuration, or flush pending duplicate/coalesced summaries. It excludes messages, source, context, arbitrary extras, runtime identifiers, paths, argv and catalog/config snapshots. It never claims complete history: filtering, buffer retention and pending summaries can omit observations. Fingerprints support correlation and are not a secrecy guarantee.
collect_bundle() and export_bundle() remain the separate rich support
exports with their own redaction settings. Choose the profile deliberately;
entering a restrictive capture scope does not erase previously retained data
or sanitize a rich bundle's configuration and provenance sections.
Level: Advanced / CI-support
Two usability rules now apply:
- Human-readable handlers may truncate very large structured payload fragments for
qa,dev, anddebugprofiles. The underlying event payload is not altered. - Repeated transient warnings can be coalesced with
warning_coalesce_window_s. The first warning is emitted normally; suppressed duplicates are summarized incoalesced_warnings.
Use coalescing only for clearly repeated transient diagnostics such as download retries, not for semantically distinct warnings.
More general duplicate handling is also available through duplicate_policy, keyed by incident fingerprint. The current pre-1.0 supported policies are:
offemit_summaryemit_every_n
Use duplicate policies for genuinely repeated incidents where aggregate counts are more useful than verbatim repetition.
Level: Advanced / CI-support
Install the optional smonitor[pytest] extra to use Pytest Receptor
>=1.2,<2. Conda users install pytest and pytest-receptor>=1.2,<2
alongside SMonitor. The bridge loads with pytest; activate the receptor's
llm (local) or ci profile and explicitly request its artifact:
mkdir -p .pytest-receptor
python -m pytest --receptor=ci \
--receptor-events=.pytest-receptor/events.jsonlPytest Receptor owns the canonical JSONL stream and execution result. SMonitor
contributes bounded uibcdf.smonitor@1 extensions containing diagnostic
identity and pytest node/phase/worker/attempt correlation. The bridge omits
free-form messages, context and arbitrary extras. Receptor pattern redaction
does not guarantee removal of arbitrary sensitive data; choose identifiers,
artifact access and retention deliberately. Neither tool uploads artifacts.
Default observation preserves pytest's outcome.
From >=0.22.0, CI can separately evaluate the artifact with explicit rules:
smonitor pytest-check .pytest-receptor/events.jsonl \
--provider-root ./mylib \
--forbid-code MYLIB-DEPRECATED-001Each trusted provider root contains _smonitor.py, which is executable Python
configuration. Repeat --provider-root for every provider whose emitted codes
the gate should accept. This validates declaration structure and emitted code
membership; representative diagnostic tests must still verify template fields
and rendering. The command returns JSON and exits 0 for policy success, 1
for violations, or 2 for invalid inputs/dependencies. CI must check both this
exit status and pytest's original status; pytest_outcome in the result is
informational. A shell step can preserve both decisions even when pytest fails:
mkdir -p .pytest-receptor
pytest_status=0
python -m pytest --receptor=ci \
--receptor-events=.pytest-receptor/events.jsonl || pytest_status=$?
policy_status=0
smonitor pytest-check .pytest-receptor/events.jsonl \
--forbid-code MYLIB-DEPRECATED-001 || policy_status=$?
if [ "$pytest_status" -ne 0 ]; then
exit "$pytest_status"
fi
exit "$policy_status"After reviewing a representative passing run, explicitly accept its identities:
smonitor pytest-baseline .pytest-receptor/events.jsonl \
--out tests/accepted_diagnostics.json
smonitor pytest-check .pytest-receptor/events.jsonl \
--baseline tests/accepted_diagnostics.jsonThe versioned baseline is a set of (code, source, fingerprint) for warning,
error and critical events. New identities fail; disappeared ones are reported
for review. Counts, worker IDs and credited test nodes do not gate the result,
because worker-local caches can vary their assignment under xdist. Fingerprints
also include selected structured fields such as caller, resource and retry
metadata: baseline only workflows with stable inputs, and use forbidden-code
rules when that context legitimately varies.
Partial, integrity-invalid, execution-incomplete, truncated, or
extension-incomplete evidence cannot pass the gate. Baseline creation also
requires a passing pytest outcome; an empty baseline requires --allow-empty.
Zero recorded diagnostics cannot prove the producer was active. Use
--require-diagnostics in a smoke gate that should observe at least one event.
Keep test-local assertions in pytest.warns, pytest.raises and structured
contract tests. The policy adds no fixture, marker, second reporter or
pytest-molsyssuite package. Standalone support bundles keep their own coverage
and redaction contract; no automatic link or equal-history guarantee is implied.
See the pytest policy contract
for details and limits.
Level: Recommended
SMonitor captures all exceptions by default as ERROR. For functions that perform exploratory checks (e.g., "is this string a unit?"), this creates log noise.
Use exception_level="DEBUG" in the @signal decorator to silence expected failures in normal operation.
@signal(tags=["check"], exception_level="DEBUG")
def is_valid_format(data):
# If this raises, it will be logged as DEBUG, not ERROR
...For functions that must succeed (e.g., "parse this unit"), keep the default ERROR level. If a user provides malformed input where a valid one is expected, it is an error.
Level: Mandatory
An integration can be wired correctly and still be silently useless. The five failure modes below raise nothing, fail no test and print no warning — they only make your diagnostics say less than they should, and they are found months later by a user who reports "the error message was blank". Each check below is one assertion, and each corresponds to a defect that reached a released library in this ecosystem.
smonitor --validate-config --config-path mylibIt prints the effective configuration and exits 0, or names every key it does
not recognise and exits 2, so it works as a CI gate as it stands.
Run it against an installed wheel, not only in your checkout. A _smonitor.py
outside the package is found in a checkout and is absent once installed, and that
difference is invisible from a development environment.
A catalog entry carries code, source, category and level; the wording
lives in CODES. A code present in one and absent from the other emits an event
with an empty message, and nothing complains.
Catalogs may be grouped under exceptions, warnings, info or errors, or
flat with entries keyed directly by class/key. The check must read both shapes
and must fail if it finds no coded entries; otherwise an empty or flat catalog
can pass without checking a single code.
A profile reads its own field and falls back to the others (section 1.2), so one message field is enough. But a code whose entry defines no message field renders empty everywhere, and a QA or agent session is exactly where nobody is watching.
Section 3.3.1 explains why. The guard is that rebuilding from args reproduces
args:
type(exc)(*exc.args).args == exc.argsTest this and not only pickle: pickle restores the instance dictionary
afterwards, so it comes out correct even for a class written the wrong way.
It is the args-only rebuilders — warnings.warn(text, category) and
pytest-xdist between a worker and the controller — that expose the defect, and
args idempotence is what they test.
CatalogException and CatalogWarning own code, message, raw_message,
extra and hint, and assign them last, from what they were given. A
subclass that sets one of those before calling super().__init__() is writing
into a variable the base is about to overwrite. Nothing raises; the value is
simply discarded, and the day a subclass computes a value different from the
base's is the day it is silently lost.
hint protects itself — it is a read-only property, so assigning it raises at the
offending line. The other four cannot, so a static check stands in for them.
# tests/test_smonitor_integration.py
import ast
import pathlib
import pickle
import pytest
import smonitor
import mylib
from mylib._private.smonitor import CATALOG
from mylib._private.smonitor.catalog import CODES
PROFILES = ["user", "dev", "qa", "agent", "debug"]
def _catalog_codes(catalog):
for key, entry in catalog.items():
if key in ("exceptions", "warnings", "info", "errors") and isinstance(entry, dict):
entries = entry.values()
else:
entries = (entry,)
for item in entries:
if isinstance(item, dict) and item.get("code"):
yield item["code"]
def test_every_catalog_code_has_a_template():
emitted_codes = set(_catalog_codes(CATALOG))
assert emitted_codes, "no catalog codes found"
orphans = sorted(emitted_codes - set(CODES))
assert not orphans, f"emitted with no template in CODES: {orphans}"
@pytest.mark.parametrize("profile", PROFILES)
def test_every_code_renders_in_every_profile(profile):
smonitor.configure(profile=profile, handlers=[], codes=CODES)
empty = [code for code in CODES if not smonitor.resolve(code=code, extra={})[0]]
assert not empty, f"empty message under {profile!r}: {empty}"
@pytest.mark.parametrize(
"build",
[
# One builder per catalog class you define, in the shape a call site uses.
lambda: UnknownAtomNameWarning("Atom 'Ar' is unknown.", atom_name="Ar"),
],
)
def test_catalog_classes_survive_a_rebuild(build):
original = build()
assert type(original)(*original.args).args == original.args
assert str(pickle.loads(pickle.dumps(original))) == str(original)
# Names the base classes own. Add your own base classes to BASES: a subclass is
# recognised through them, and they usually live in a module of yours rather
# than being imported from SMonitor at every definition.
RESERVED = {"code", "message", "raw_message", "extra", "hint"}
BASES = {"CatalogException", "CatalogWarning", "MyLibCatalogError"}
PACKAGE = pathlib.Path(mylib.__file__).parent
def _catalog_classes(tree):
known, found, changed = set(BASES), set(), True
while changed:
changed = False
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name not in found:
names = {getattr(b, "id", getattr(b, "attr", "")) for b in node.bases}
if names & known:
found.add(node.name)
known.add(node.name)
changed = True
return found
def test_no_catalog_class_assigns_a_base_owned_name():
offenders = []
for path in sorted(PACKAGE.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
classes = _catalog_classes(tree)
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
if cls.name not in classes:
continue
for node in ast.walk(cls):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if (
isinstance(target, ast.Attribute)
and target.attr in RESERVED
and getattr(target.value, "id", "") == "self"
):
where = f"{path.name}:{node.lineno}"
offenders.append(f"{where} {cls.name}.self.{target.attr}")
assert not offenders, "assigns a name a base class owns: " + "; ".join(offenders)Add the bundle smoke from the minimum recipe alongside it — smonitor export
followed by reading triage — and the integration is verified end to end.
The file above is yours: it runs in your suite, on your schedule, and check 4 needs builders only you can write. When the question is instead where does every library stand, SMonitor ships the same checks as a sweep:
python devtools/verify_integration.py ../mylib # one
python devtools/verify_integration.py --all # every sibling carrying this guideIt covers checks 1, 2, 3 and 5, reads your catalog without importing your library, and exits non-zero on any failure, so it works as a gate. Check 4 stays in your own test file.
- Zero String Hardcoding: If it's a warning or error, it belongs in the catalog.
- Lazy Diagnostics: Do not perform expensive string formatting before calling
emit. Pass raw data inextraand let SMonitor handle the interpolation. - Traceability First: Use
@signalgenerously in orchestration layers but avoid it in high-frequency tight loops. - Template Wiring Integrity: Every emitted catalog code must have a matching template in the active
_smonitor.pyconfiguration. A code with no template emits an event whose message is the empty string — no exception, no warning, nothing in the logs. Check 3 of section 7 is the assertion that catches it. - No Silent Emission Failures: Never hide failed catalog emissions without an explicit fallback diagnostic.
Document created on February 6, 2026, as the authority for SMonitor integration.