Skip to content

chore(release): 0.18.1 — lower-friction UX (lazy @protect + dev error report + zero-activity diagnostic + drop dead extras + deprecate langgraph.wrapper) - #110

Merged
maltsev-dev merged 6 commits into
masterfrom
release/0.18.1
Sep 22, 2026
Merged

maltsev-dev merged 6 commits into
masterfrom
release/0.18.1

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Patch release 0.18.1 — lower-friction UX. Closes four silent-failure modes at once:

  1. @protect auto-attaches a default tool-params extractor (fcd623c). The first call from @protect stamps ToolParamsExtractor(include_all=True) on the decorated function so the wire payload carries tool_name + params for every protected call — no separate @sensitive needed for the common case. Bare @sensitive is now deprecated (DeprecationWarning in 0.18.x, removed in 0.19.x); @sensitive(impact=...) remains the advanced API for typed BusinessImpact + SHA-256 action_digest.

  2. @protect lazy-triggers auto_instrument() on first invocation. The user can write @protect before init_or_die() (or skip init entirely if NULLRUN_API_KEY is set); the runtime is created on first gate call and patches httpx + framework adapters in a single process-wide idempotent step. Closes the "I added @protect but nothing tracks tokens" silent-failure mode.

  3. Zero-activity diagnostic on NullRunRuntime (DEF-ZERO-ACTIVITY-DIAG). When @protect fires 50+ times without a single LLM event, the runtime emits a one-time WARNING at logging.WARNING naming the three most likely root causes (raw httpx outside the patchable surface, custom transport / gRPC, framework not on the auto-detection table). Warn-once invariant under concurrent @protect calls is preserved by _zero_activity_lock.

  4. Four-line developer error report from handle() / guarded() / init_or_die() (DEF-DEV-REPORT-EMPTY). The catch-all exit path previously printed only the catalog user-message — end-user wording that gave a developer zero actionable detail. The new _render_dev_error_report() helper emits a structured report answering the four questions a developer actually asks: what (stage that failed) / where (wire endpoint + status + transport source) / why (underlying exception message + machine error_code) / how to fix (typed class user_action). Defensive try/except fallback to the legacy single-line behaviour so a buggy helper cannot freeze a script that would otherwise exit.

Plus two cleanups:

  1. Removed dead pip extras ([openai], [anthropic], [mistral], [gemini], [cohere], [bedrock], [all], [fastapi]). NullRun never imported these vendor SDKs — HTTP-level instrumentation (patch_httpx + 5 URL-keyed extractors) covers OpenAI / Azure / Anthropic / Mistral / Gemini / Cohere / Bedrock without them. pip install nullrun alone is now sufficient for the HTTP-level + @protect flow. Framework extras stay.

  2. toolbox.langgraph.wrapper() marked DEPRECATED in its docstring. init_or_die() / @protect auto-patches langgraph.pregel.Pregel via patch_langgraph_compiled — same callback injection the wrapper does manually, but process-wide and idempotent. wrapper() remains as an escape hatch for three narrow cases (tests with custom runtimes, Pregel imported before init, manual callback control). No removal — the public symbol stays.

Wire-format unchanged. SDK_MIN_VERSION unchanged.

Added

  • DEF-ZERO-ACTIVITY-DIAG — zero-activity diagnostic on NullRunRuntime (src/nullrun/runtime.py, 0d6a6b9). _bump_protect_count() is invoked by @protect on every call; _llm_call_event_count is bumped by track_llm() on every successful call. When @protect fires 50+ times without a single LLM event, the runtime emits a one-time WARNING at logging.WARNING naming the three most likely root causes. Warn-once invariant under concurrent @protect calls is preserved by _zero_activity_lock. 6 new tests in tests/test_zero_activity_diagnostic.py pin the warn-once / threshold / concurrent-bump / message-content invariants.

  • DEF-DEV-REPORT-EMPTY — four-line developer error report from handle() / guarded() / init_or_die() (src/nullrun/_handle.py, cc79241). New helper _render_dev_error_report() reads error_code / user_action / retryable / docs_url / endpoint / status_code / source off the exception, caps the why: line at 400 chars + ellipsis, and emits a structured report answering what / where / why / how to fix / docs: questions. The catalog headline is preserved as the first line so end-user-facing deployments still get a clean single sentence. Defensive try/except fallback in handle() / init_or_die() so a buggy helper cannot freeze a script that would otherwise exit. 11 new tests in tests/test_dev_error_report.py pin the four-line / what / where / why / how-to-fix / docs-URL invariants.

  • @protect lazy-triggers auto_instrument() on first invocation (src/nullrun/decorators.py, e62718f part of fcd623c). The first decorated function call installs the runtime and patches httpx + framework adapters in a single process-wide idempotent step, behind a lock so concurrent @protect calls cannot double-fire. Never raises — a vendor SDK breaking change must not block the enforcement gate. Closes the "I added @protect but nothing tracks tokens" silent-failure mode.

  • @protect auto-attaches a default tool-params extractor (src/nullrun/decorators.py + src/nullrun/extractor.py, e62718f part of fcd623c). The first call from @protect stamps ToolParamsExtractor(include_all=True) on the decorated function so the wire payload carries tool_name + params for every protected call. The auto-attached extractor carries _nullrun_auto_attached=True so _enforce_sensitive_tool can distinguish "developer opted into the policy path" from "SDK auto-derived for tooling reasons"; the policy gate short-circuits on auto-attached extractors so bare @protect stays cheap (no extra /execute round-trip). Bounded extraction: oversized string values (>=1024 bytes) get a deterministic ...[truncated:N bytes] suffix; circular references return the partial walk instead of raising RecursionError; dropped values (float, bytes, custom) emit a single aggregate DEBUG log line with count + type names, never one log line per dropped field. 9 new tests in tests/test_protect_only_public_api.py pin the auto-attach / chain-walk / bare-@sensitive-deprecation invariants.

Changed

  • Removed dead provider extras from pyproject.toml (ab8c183): [openai], [anthropic], [mistral], [gemini], [cohere], [bedrock], [all], [fastapi]. NullRun never imported these vendor SDKs — HTTP-level instrumentation (patch_httpx + 5 URL-keyed extractors) covers OpenAI, Azure, Anthropic, Mistral, Gemini, Cohere, and Bedrock without them. Kept framework extras ([opentelemetry], [langgraph], [agents], [langchain], [llama-index], [crewai], [autogen]) — those still install a vendor SDK that NullRun subscribes to via an event hook. pip install nullrun alone is now sufficient for the HTTP-level + @protect flow.

  • toolbox.langgraph.wrapper() marked DEPRECATED in its docstring (9a0c2de, src/nullrun/toolbox/langgraph.py). init_or_die() / @protect auto-patches langgraph.pregel.Pregel via patch_langgraph_compiled — same callback injection the wrapper does manually, but process-wide and idempotent. wrapper() remains as an escape hatch for three narrow cases (tests with custom runtimes, Pregel imported before init, manual callback control). No removal — the public symbol stays.

  • Bare @sensitive emits DeprecationWarning (e62718f part of fcd623c, src/nullrun/decorators.py). @sensitive(impact=...) remains the advanced API for typed BusinessImpact + SHA-256 action_digest. Bare @sensitive is removed in 0.19.x; emit is DeprecationWarning in 0.18.x only.

  • README — new Framework adapters — auto-detected subsection enumerates the six framework adapters (LangGraph / LangChain / OpenAI Agents / LlamaIndex / CrewAI / AutoGen) with their patch point + trigger (c7029cb). Makes explicit that auto-detection activates when init_or_die() runs (or when @protect first fires) — the user does NOT need to choose which extra to install. Also clarifies the lazy-trigger contract: If you call @protect before init_or_die(), the SDK auto-triggers instrumentation lazily on the first decorated call.

  • docs/errors/NR-C001.md — reflow for the 0.18.1 lazy-trigger semantics (c7029cb). NR-C001 now surfaces at the first @protect call (runtime is created lazily on first gate call from the environment), not at init(). The typed exception class is documented as NullRunConfigError (was historically NullRunAuthenticationError for back-compat).

Tests

  • tests/test_zero_activity_diagnostic.py (new, 6 tests) — pins the warn-once / threshold / concurrent-bump / message-content invariants on the zero-activity diagnostic.
  • tests/test_dev_error_report.py (new, 11 tests) — pins the four-line / what / where / why / how-to-fix / docs-URL invariants on the new error report.
  • tests/test_protect_only_public_api.py (new, 9 tests, landed in fcd623c) — pins the auto-attach / chain-walk / bare-@sensitive-deprecation invariants on the @protect contract change.
  • tests/test_protect.py, tests/test_preflight_fail_policy.py, tests/test_protect_cancel_on_exception.py_RecordingRuntime stubs extended with a _bump_protect_count no-op so the existing gate / cancel / span test surface keeps working unchanged.

Verification

Check Result
ruff check src tests All checks passed
mypy src/nullrun Success: no issues found in 37 source files
pytest -q 1840 passed, 4 skipped, 14 warnings in ~128s (vs 0.18.0 baseline of 1814 passed, 4 skipped — +26 new tests: 9 from fcd623c, 11 from test_dev_error_report.py, 6 from test_zero_activity_diagnostic.py)
nullrun.__version__ 0.18.1
Scratch diff clean (no dist_local/, no *.defect*)
Wire-format unchanged. SDK_MIN_VERSION — unchanged.

Commits included

e62718f chore(release): 0.18.1 — lower-friction UX (lazy @protect + dev error report + zero-activity diagnostic + drop dead extras + deprecate langgraph.wrapper)
cc79241 feat(sdk): handle()/guarded()/init_or_die() emit four-line dev error report (DEF-DEV-REPORT-EMPTY)
0d6a6b9 feat(runtime): zero-activity diagnostic -- warn when @protect fires 50x with no LLM events (DEF-ZERO-ACTIVITY-DIAG)
9a0c2de chore(toolbox): mark langgraph.wrapper() DEPRECATED -- auto-patch is the canonical path
ab8c183 chore(pyproject): drop dead provider extras ([openai], [anthropic], etc.)
c7029cb docs(sdk): README framework-adapter auto-detection table + NR-C001 lazy-trigger note

… report + zero-activity diagnostic + drop dead extras + deprecate langgraph.wrapper)
…report (DEF-DEV-REPORT-EMPTY)

The catch-all exit path previously printed only the catalog user-message
("There's a configuration issue. Please contact support.") to stderr.
That wording is correct for end-users but gave a developer running an
example with a missing NULLRUN_API_KEY zero actionable detail -- they
saw the friendly line, the script exited 1, and they had no way to
tell whether the failure was auth, gate, transport, or config.

New helper nullrun._handle._render_dev_error_report renders a four-line
report answering the four questions a developer actually asks:

  1. what   -- the stage that failed (auth / gate / track / ...)
  2. where  -- the wire endpoint + status code + transport source
  3. why    -- the underlying exception message + machine error_code
  4. how to fix -- the user_action from the typed class

Plus an optional fifth docs: line carrying the docs URL.

The catalog format_user_message wording is the headline so end-user-
facing deployments still get a clean single sentence; the structured
detail follows on its own line.

Defensive: handle()/init_or_die() wrap the helper in try/except so a
buggy report builder cannot freeze a script that would otherwise exit.
Falls back to the legacy single-line behaviour.

- src/nullrun/_handle.py:
  - _render_dev_error_report(exc, user_message): new -- four-line
    structured report. Reads error_code / user_action / retryable /
    docs_url / endpoint / status_code / source off the exception.
    Caps why: line at 400 chars + ellipsis so a verbose backend
    response doesn't blow up the terminal.
  - handle(): catches NullRunError, calls _render_dev_error_report,
    prints to stderr, sys.exit(exit_code). Defensive fallback to
    format_user_message if the helper raises.
  - init_or_die(): same path -- a missing API key at startup now
    prints the four-line report naming the missing env var, the
    dashboard URL to obtain a key, and the docs page, instead of
    just the catalog headline.

- tests/test_dev_error_report.py (new, 11 tests):
  - test_report_includes_what_where_why_and_fix
  - test_report_stage_derived_from_class_name_when_no_endpoint
  - test_report_includes_transport_endpoint_and_source
  - test_report_truncates_long_underlying_messages
  - test_report_omits_fix_line_when_user_action_is_empty
  - test_report_includes_docs_url
  - test_handle_prints_full_dev_report
  - test_guarded_prints_full_dev_report
  - test_handle_falls_back_to_legacy_on_helper_bug
  - test_handle_report_uses_class_name_for_unknown_endpoint
  - test_handle_and_guarded_do_not_require_runtime

11/11 pin tests pass. Wire-format unchanged. SDK_MIN_VERSION unchanged.
…0x with no LLM events (DEF-ZERO-ACTIVITY-DIAG)

The user's @Protect gate is enforced, but enforcement without
observability means the dashboard reports nothing -- the agent runs,
costs nothing, and the operator is left wondering whether NullRun is
wired up correctly. Silent-failure mode that surfaces only as
"the dashboard shows zero tokens" weeks later.

New diagnostic on NullRunRuntime:
  - _protect_call_count        -- bumped by _bump_protect_count(),
                                  called from @Protect on every call.
  - _llm_call_event_count      -- bumped by track_llm() on every
                                  successful call.
  - _zero_activity_warned      -- flag set when the warning fires,
                                  prevents spam on long-lived processes.
  - _zero_activity_lock        -- guard around the read-flag sequence
                                  so concurrent @Protect calls cannot
                                  produce duplicate warnings.

Threshold: 50 @Protect calls without a single LLM-call event is the
operational signal. The lock + flag combination makes the warn-once
invariant survive concurrent fanout.

The warning text names the three most likely root causes so the
operator can self-diagnose without consulting external docs
immediately:
  (1) raw httpx client without NullRun's instrumentation patches,
  (2) custom transport / non-HTTP vendor (gRPC, WebSocket, SDK-
      internal socket),
  (3) framework not in the auto-detection table.

- src/nullrun/runtime.py:
  - NullRunRuntime.__init__: initialise the four new attributes.
  - _bump_protect_count(): atomic int increment + call
    _maybe_warn_zero_activity(). Cheap; GIL makes the int bump
    atomic without a lock.
  - _maybe_warn_zero_activity(): under _zero_activity_lock, return
    early if already warned or below threshold; otherwise check
    _llm_call_event_count == 0, set the flag, emit the WARNING.
  - track_llm(): bump _llm_call_event_count as the canonical "yes
    we saw an LLM call" answer. Single line, no behaviour change
    to the rest of track_llm.

- tests/test_zero_activity_diagnostic.py (new, 6 tests):
  - test_no_warning_below_threshold
  - test_warns_at_threshold_when_no_llm_events
  - test_warn_once_only
  - test_no_warning_after_first_llm_event
  - test_warning_message_mentions_root_causes
  - test_concurrent_bumps_warn_at_most_once

- tests/test_protect.py, tests/test_preflight_fail_policy.py,
  tests/test_protect_cancel_on_exception.py: _RecordingRuntime stubs
  extended with a _bump_protect_count no-op so the existing gate /
  cancel / span test surface keeps working unchanged (the diagnostic
  itself is exercised by dedicated tests).

6/6 new pin tests pass. Wire-format unchanged. SDK_MIN_VERSION
unchanged.
…the canonical path

For typical LangGraph usage, nullrun.init_or_die() (or just @Protect on
the agent function) auto-patches langgraph.pregel.Pregel via
nullrun.instrumentation.auto.patch_langgraph_compiled -- the same
callback injection that this wrapper() performs manually. The auto-
patch is process-wide and idempotent; wrapper() was a one-app-at-a-time
mutation that 90%+ of users no longer need to call.

This commit makes wrapper() an explicit escape hatch for three narrow
cases instead of the default recommendation:

  1. Tests with custom runtimes (the auto-patch binds to the active
     runtime; a test fixture that swaps runtimes mid-flight may need
     the wrapper to attach to the new runtime directly).
  2. Apps where Pregel is imported BEFORE nullrun.init_or_die() AND
     the import side-effects register a non-Pregel transport that the
     auto-patch cannot reach.
  3. Manual control over which NullRunCallback instance is attached
     (rare; the default singleton is usually correct).

No behaviour change. The wrapper() public symbol stays -- no removal in
0.18.x; only the docstring + module docstring now describe the
auto-patch as the canonical path with the wrapper as the escape hatch.

- src/nullrun/toolbox/langgraph.py:
  - Module docstring: rewritten to position auto-patch as canonical,
    wrapper() as escape hatch for the three documented cases.
  - wrapper() docstring: .. deprecated:: block added; usage example
    moved to a 'if you must use this wrapper' sub-section.

Wire-format unchanged. SDK_MIN_VERSION unchanged.
…tc.)

NullRun never imports the vendor SDKs listed in [openai] / [anthropic]
/ [mistral] / [gemini] / [cohere] / [bedrock]. HTTP-level
instrumentation (patch_httpx + 5 URL-keyed extractors) covers OpenAI,
Azure, Anthropic, Mistral, Gemini, Cohere, and Bedrock without those
vendor packages -- all of those vendors route through httpx, and
NullRun parses the response body by URL host. Installing the extras
was dead weight that pulled vendor SDKs into user environments for
no observable benefit.

Kept framework extras ([opentelemetry], [langgraph], [agents],
[langchain], [llama-index], [crewai], [autogen]) -- those still
install a vendor SDK that NullRun subscribes to via an event hook
(langgraph.pregel.Pregel, langchain_core.callbacks.BaseCallbackManager,
agents.Runner, llama_index.core.instrumentation, crewai.Crew,
autogen_agentchat.agents.BaseChatAgent).

Also dropped:
  - [fastapi]: the integrations submodule is lazy-imported; users
    who don't use FastAPI don't pay its install cost, and
    nullrun.integrations.__init__ doesn't change.
  - [all]: the convenience meta-extra was a copy of the above; with
    provider extras gone, [all] is now identical to the framework-
    extras list and was removed.

Result: 'pip install nullrun' alone is now sufficient for the HTTP-
level + @Protect flow. Framework auto-instrumentation activates
transparently when the user installs any one of the framework extras.

- pyproject.toml [project.optional-dependencies]:
  - Removed: openai, anthropic, mistral, gemini, cohere, bedrock,
    fastapi, all.
  - Kept + expanded comment: opentelemetry, langgraph, agents,
    langchain, llama-index, crewai, autogen.

Wire-format unchanged. SDK_MIN_VERSION unchanged.
…zy-trigger note

README: new 'Framework adapters -- auto-detected' subsection enumerates
the six framework adapters (LangGraph / LangChain / OpenAI Agents /
LlamaIndex / CrewAI / AutoGen) with their patch point + trigger. Makes
explicit that auto-detection activates when init_or_die() runs (or when
@Protect first fires) -- the user does NOT need to choose which extra
to install.

Also clarifies the lazy-trigger contract: 'If you call @Protect before
init_or_die(), the SDK auto-triggers instrumentation lazily on the
first decorated call.' This is the user-facing version of the
0.18.1 lazy-init change that landed in fcd623c.

docs/errors/NR-C001.md: reflow for the 0.18.1 lazy-trigger semantics.
NR-C001 now surfaces at the first @Protect call (runtime is created
lazily on first gate call from the environment), not at init(). The
typed exception class is documented as NullRunConfigError (was
historically NullRunAuthenticationError for back-compat). The 'When'
section is rewritten to describe the new trigger point; the 'Why this
raises' section adds a 0.18.1 note explaining what changed (when, not
what); the catch-pattern example uses @nullrun.protect instead of
nullrun.init() to match the new code surface; the 'See also' list adds
NR-C004 (nullrun.status() called before the runtime is bound).

- README.md: new subsection after the 'Quick start' block; existing
  'How NullRun compares' section unchanged.
- docs/errors/NR-C001.md: 'When', 'Why this raises', 'How to fix',
  'Catch pattern', 'See also' all updated; 'Default user_action' row
  in the header table updated to surface NullRunConfigError as the
  typed class.

No code change. No behaviour change.
@maltsev-dev
maltsev-dev merged commit ca83ec6 into master Sep 22, 2026
4 checks passed
@maltsev-dev
maltsev-dev deleted the release/0.18.1 branch September 22, 2026 10:51
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.59690% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/extractor.py 82.05% 6 Missing and 1 partial ⚠️
src/nullrun/_handle.py 87.80% 3 Missing and 2 partials ⚠️
src/nullrun/decorators.py 86.66% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant