Skip to content

Latest commit

 

History

History
1047 lines (799 loc) · 43.3 KB

SMONITOR_GUIDE.md

File metadata and controls

1047 lines (799 loc) · 43.3 KB

SMonitor Guide (Canonical)

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

What is SMonitor

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.

Why this matters in this library

  • 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 agent profile.

Integration levels

Treat this guide in three layers:

  1. Mandatory
    • required for a correct SMonitor integration in any sibling library.
  2. Recommended
    • expected for modern QA/support readiness.
  3. Advanced / CI-support
    • valuable once the mandatory and recommended layers are already in place.

If time is constrained, implement in that order.

Minimum modern integration recipe

At minimum, a sibling library should have:

  1. _smonitor.py plus private catalog/meta files.
  2. ensure_configured(PACKAGE_ROOT) in package initialization (first-use bootstrap only), or register_provider(PACKAGE_ROOT, provider="mylib") for declaration-only registration. Both preserve an already configured application policy. Only explicit use_provider_policy=True opts into reconfiguration. Applications requiring metadata-only capture should enter smonitor.diagnostic_scope(safe_extra={...}) before provider calls; providers inspect get_capture_policy() before constructing native-error payloads. See docs/content/user/library-integrators/scoped-capture.md for permissions, bounded primitives, task/thread propagation and ownership limits.
  3. catalog-driven emission via DiagnosticBundle / catalog exceptions and warnings.
  4. @signal on public orchestration entry points.
  5. context_extra(...) for repeated structured diagnostic fields.
  6. the verification tests of section 7, plus one smoke test covering bundle export.

Version boundary for the modern capabilities

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.

1. Required Configuration Structure

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: Exports CATALOG, META, and PACKAGE_ROOT.

1.1 Single Source of Truth for Templates

CODES and SIGNALS must be resolved from exactly one authoritative place.

Recommended pattern:

  • define CATALOG, CODES, and SIGNALS in mylib/_private/smonitor/catalog.py;
  • in _smonitor.py, import them from mylib._private.smonitor.catalog.

This avoids drift where emitted catalog codes exist but template messages are missing at runtime.

1.2 Profile fields, and what happens when one is missing

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.

1.3 Safe metadata wording by audience (optional, >=0.21.0)

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.

2. Initialization Protocol

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)

3. Emission via Catalog (Mandatory)

Level: Mandatory

All diagnostic output must be driven by the catalog. Never hardcode strings in the scientific logic.

3.1. Standard Warning Helper

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.resolve

3.2. Exceptions

All 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 ...

3.3. Warnings

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.

3.3.1 Pass structured data, not rendered sentences

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.

Declare the message first, and the fields keyword-only

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.

3.3.2 warn() also raises an ordinary Python warning

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().

3.4 Emission Failures Must Not Be Silenced

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.

3.5 What to write, and what it buys you

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.

Four places, one job each

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.

The same diagnostic, written both ways

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.

When the value really is prose

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.

Choosing a code

  • 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.

What each field buys, and what you lose without 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.

The fingerprint: which fields group two occurrences into one incident

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 resource that 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_attempt is on the list, so a retry loop that stamps it produces a different fingerprint per attempt. Fingerprints therefore do not collapse retries, and neither does duplicate_policy, which keys on the fingerprint. warning_coalesce_window_s is what collapses them: it keys on code, source, resource, caller and message instead. Use coalescing for retry storms and duplicate_policy for 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.

4. Telemetry with @signal

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.

5. Signal Contracts

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.

5.1 Structured Signal Context and Profiling

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 exposes timings_by_tag in addition to timings by function and module.
  • slow_signal_ms and slow_signal_level enable 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.

5.2 Canonical Structured Context Helper

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 evidence for compact expected/observed/resource/operation facts.

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"},
)

5.3 Report, Bundle, and Machine-Oriented Output

Level: Advanced / CI-support

SMonitor now exposes QA-oriented summaries beyond raw event streams:

  • events carry stable fingerprint, run_id, session_id, and optional correlation_id.
  • events also carry a stable additive human_summary block for concise human-facing handoff.
  • report() includes events_by_code, events_by_category, events_by_fingerprint, slow_signals_recent, and coalesced_warnings.
  • report() also exposes operational triage sections such as top_codes, top_sources, top_fingerprints, most_noisy_resources, most_expensive_entries, blocking_incidents, actionable_incidents, and recurrent_incidents.
  • bundle exports mirror those summaries under triage.
  • bundle exports also carry a runtime block and can be compared locally with smonitor compare.
  • JsonHandler includes a normalized payload section with stable machine-oriented fields for cross-library ingestion, including retry/causal metadata, decision metadata, structured evidence, and mirrored human_summary.

These additions should be treated as the preferred source for automated QA summaries before scanning raw event buffers.

Minimal CI/support flow:

  1. run the representative workflow;
  2. export a local bundle inside the same diagnostic-producing process;
  3. inspect triage first, not raw events;
  4. compare against a previous bundle when asking “what changed?”;
  5. 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.

5.3.1 Bounded identity support bundles (optional, >=0.21.0)

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.

5.4 Human-Readable Output and Coalescing

Level: Advanced / CI-support

Two usability rules now apply:

  • Human-readable handlers may truncate very large structured payload fragments for qa, dev, and debug profiles. 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 in coalesced_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:

  • off
  • emit_summary
  • emit_every_n

Use duplicate policies for genuinely repeated incidents where aggregate counts are more useful than verbatim repetition.

5.5 Pytest evidence and optional QA policy

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.jsonl

Pytest 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-001

Each 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.json

The 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.

6. Noise Control

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.

Exploratory Functions

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
    ...

Assertive Parsing

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.

7. Verify the integration

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.

Check 1 — the configuration is found and understood

smonitor --validate-config --config-path mylib

It 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.

Check 2 — every code you emit has a template

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.

Check 3 — every code renders in every profile

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.

Check 4 — catalog classes survive being rebuilt

Section 3.3.1 explains why. The guard is that rebuilding from args reproduces args:

type(exc)(*exc.args).args == exc.args

Test 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.

Check 5 — no class assigns a name the base owns

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.

One file that does all five

# 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.

Running the checks from outside

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 guide

It 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.

Required behavior (non-negotiable)

  1. Zero String Hardcoding: If it's a warning or error, it belongs in the catalog.
  2. Lazy Diagnostics: Do not perform expensive string formatting before calling emit. Pass raw data in extra and let SMonitor handle the interpolation.
  3. Traceability First: Use @signal generously in orchestration layers but avoid it in high-frequency tight loops.
  4. Template Wiring Integrity: Every emitted catalog code must have a matching template in the active _smonitor.py configuration. 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.
  5. 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.