diff --git a/CHANGELOG.md b/CHANGELOG.md index ae89cc9..4d7b234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,7 @@ under Unreleased and move into a version section at release time. ## Unreleased -### Fixed - -- Enabling the `huggingface` hub no longer imports unused transformers model - modules. To find references to `hf_hub_download` and `snapshot_download`, - the hub tracker looked up the name on every loaded module, and transformers' - lazy modules import code on lookup. With transformers loaded this imported - about 140 extra modules, took about 0.15 s and logged about 430 "failed to - patch" debug lines. The tracker now reads each module's namespace instead. - -## 0.3.0 - 2026-10-21 +## 0.3.0 - 2026-10-24 ### Added @@ -48,10 +39,6 @@ under Unreleased and move into a version section at release time. and environment configuration. `gguf` remains a compatibility alias; `all` selects the integration once under its new name. Model format metadata remains `gguf`. -- Automatic integrations no longer attach raw payloads, including GGUF prompts, - chat messages and replies. `attachments_enabled=True` enables uploads only for - attachments explicitly passed to `track_inference(attachments=[...])`. - Apps relying on automatic GGUF capture must supply attachments explicitly. - `wildedge.init(dsn=...)` reuses the existing default client when the DSN points to the same project, for example `dsn=os.environ["WILDEDGE_DSN"]` under `wildedge run`. It used to create a second client. @@ -76,6 +63,12 @@ under Unreleased and move into a version section at release time. ### Fixed +- Every error event now caps `error_message` at 500 characters, keeping the + start and the end of a longer message. Integrations used to cut it to the + first 200, which lost the cause in messages that end with it, such as + mlx-whisper audio errors that start with the ffmpeg banner. Manual + `track_error()` calls had no limit at all. `provider_error_code` is capped + at 64 characters for every event. - The llama.cpp integration now tracks `Llama.create_chat_completion()` and `Llama.create_completion()`, streaming or not. It used to patch only `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but @@ -85,7 +78,8 @@ under Unreleased and move into a version section at release time. calls, the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`, `repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat calls, `prompt_type="chat"` and `turn_index` measured on the last user - message. + message. Prompts, chat messages and replies are never attached. As before, + attachments upload only when passed to `track_inference(attachments=[...])`. - `import wildedge` no longer imports every installed ML framework and API client. Each library is imported when its integration is activated or a model from it is registered, so SDK import is faster and a broken unrelated @@ -142,6 +136,12 @@ under Unreleased and move into a version section at release time. queue, so events were duplicated and, depending on timing, later events could be removed without being sent. This showed up when a process started with events saved by an earlier run and flushed soon after. +- Enabling the `huggingface` hub no longer imports unused transformers model + modules. To find references to `hf_hub_download` and `snapshot_download`, + the hub tracker looked up the name on every loaded module, and transformers' + lazy modules import code on lookup. With transformers loaded this imported + about 140 extra modules, took about 0.15 s and logged about 430 "failed to + patch" debug lines. The tracker now reads each module's namespace instead. ## 0.2.2 - 2026-09-30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bd3aac..f01f4fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Releases follow a `release/` branching flow: 1. Cut a `release/` branch from `devel`. -2. Bump the version in `pyproject.toml`. +2. Bump the version in `pyproject.toml` and move the Unreleased entries in `CHANGELOG.md` into a `## - ` section. The release notes are that section, so the release fails without it. 3. Open a pull request targeting `main`. CI will automatically run the full compatibility matrix and post a changelog preview comment. 4. Once merged, tag the commit as `v`. The release workflow publishes to PyPI and creates a GitHub release. diff --git a/scripts/build_changelog_comment.py b/scripts/build_changelog_comment.py index f05bdc6..e88bec5 100644 --- a/scripts/build_changelog_comment.py +++ b/scripts/build_changelog_comment.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 """Generate changelog text for release PRs and for tag-build release notes. +The notes are the version's section of CHANGELOG.md, so they list what changed +for users rather than every commit and merge since the previous tag. + The two callers differ only in wording: release-pr.yml renders a preview of notes that do not exist yet, while release.yml (which sets TAG_NAME) renders the notes themselves. @@ -13,6 +16,8 @@ import subprocess from pathlib import Path +CHANGELOG = Path("CHANGELOG.md") + def get_version() -> str: # Regex instead of tomllib so the script also runs on Python 3.10. @@ -24,9 +29,6 @@ def get_version() -> str: return match.group(1) -RELEASE_COMMIT_RE = re.compile(r"^Release \d+\.\d+\.\d+$") - - def get_previous_tag() -> str | None: result = subprocess.run( ["git", "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^"], @@ -37,26 +39,28 @@ def get_previous_tag() -> str | None: return tag if result.returncode == 0 and tag else None -def get_commits_since(prev_tag: str | None) -> list[str]: - revision = f"{prev_tag}..HEAD" if prev_tag else "HEAD" - result = subprocess.run( - ["git", "log", revision, "--pretty=format:%s"], - capture_output=True, - text=True, - check=True, - ) - lines = [line for line in result.stdout.splitlines() if line.strip()] - return [line for line in lines if not RELEASE_COMMIT_RE.match(line)] +def changelog_section(text: str, version: str) -> str: + """Body of the ``## `` section, up to the next ``## `` heading.""" + heading = re.compile(rf"^## \[?{re.escape(version)}\]?(?:\s|$)", re.MULTILINE) + match = heading.search(text) + if match is None: + raise SystemExit(f"CHANGELOG.md has no section for {version}") + line_end = text.find("\n", match.start()) + start = len(text) if line_end == -1 else line_end + 1 + end = re.compile(r"^## ", re.MULTILINE).search(text, start) + body = text[start : end.start() if end else len(text)].strip() + if not body: + raise SystemExit(f"CHANGELOG.md section for {version} is empty") + return body def build_comment( tag_name: str, - commits: list[str], + notes: str, prev_tag: str | None, *, preview: bool = True, ) -> str: - items = "\n".join(f"- {c}" for c in commits) if commits else "- No changes." range_str = f"{prev_tag}...{tag_name}" if prev_tag else tag_name heading = "Changelog preview for" if preview else "Changelog for" blurb = ( @@ -67,22 +71,22 @@ def build_comment( return ( f"## {heading} `{tag_name}`\n\n" f"{blurb}" - f"**What's Changed**\n\n{items}\n\n" + f"{notes}\n\n" f"**Full Changelog**: {range_str}\n" ) def main() -> None: output = os.environ.get("OUTPUT", "/tmp/changelog-preview.md") - # release.yml sets TAG_NAME on tag builds; release-pr.yml never does, so its + # release.yml sets TAG_NAME on tag builds and release-pr.yml never does. Its # absence is what distinguishes a PR preview from the real release notes. tag_name = os.environ.get("TAG_NAME") preview = not tag_name tag_name = tag_name or f"v{get_version()}" + notes = changelog_section(CHANGELOG.read_text(), tag_name.removeprefix("v")) prev_tag = get_previous_tag() - commits = get_commits_since(prev_tag) - Path(output).write_text(build_comment(tag_name, commits, prev_tag, preview=preview)) + Path(output).write_text(build_comment(tag_name, notes, prev_tag, preview=preview)) if __name__ == "__main__": diff --git a/tests/test_build_changelog_comment.py b/tests/test_build_changelog_comment.py index f3c860c..fa5af30 100644 --- a/tests/test_build_changelog_comment.py +++ b/tests/test_build_changelog_comment.py @@ -3,9 +3,34 @@ import importlib.util from pathlib import Path +import pytest + SCRIPT = Path(__file__).parent.parent / "scripts" / "build_changelog_comment.py" -COMMITS = ["Add a thing (#1)", "Fix another thing (#2)"] +CHANGELOG = """# Changelog + +Intro text. + +## Unreleased + +- Not released yet. + +## 1.2.3 - 2026-10-21 + +### Added + +- A thing. + +### Fixed + +- Another thing. + +## 1.2.2 - 2026-09-30 + +### Fixed + +- An older thing. +""" def load_script(): @@ -15,58 +40,74 @@ def load_script(): return module +def test_notes_are_the_version_section_of_the_changelog(): + module = load_script() + notes = module.changelog_section(CHANGELOG, "1.2.3") + + assert notes == "### Added\n\n- A thing.\n\n### Fixed\n\n- Another thing." + + +def test_last_section_runs_to_the_end_of_the_file(): + module = load_script() + assert ( + module.changelog_section(CHANGELOG, "1.2.2") == "### Fixed\n\n- An older thing." + ) + + +def test_bracketed_version_headings_are_found(): + module = load_script() + text = "## [2.0.0] - 2026-11-01\n\n- New.\n" + assert module.changelog_section(text, "2.0.0") == "- New." + + +def test_version_prefix_does_not_match_a_longer_version(): + module = load_script() + with pytest.raises(SystemExit, match=r"no section for 1\.2$"): + module.changelog_section(CHANGELOG, "1.2") + + +def test_missing_or_empty_section_fails_the_build(): + module = load_script() + with pytest.raises(SystemExit, match=r"no section for 9\.9\.9"): + module.changelog_section(CHANGELOG, "9.9.9") + with pytest.raises(SystemExit, match="is empty"): + module.changelog_section("## 1.0.0\n\n## 0.9.0\n\n- Old.\n", "1.0.0") + + def test_preview_wording_on_pull_requests(): module = load_script() - text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=True) + text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=True) assert text.startswith("## Changelog preview for `v1.2.3`") assert "> Preview of the release notes" in text + assert "- A thing." in text assert "**Full Changelog**: v1.2.2...v1.2.3" in text - for commit in COMMITS: - assert f"- {commit}" in text def test_release_notes_drop_the_preview_wording(): """Tag builds render the notes themselves, not a preview of them.""" module = load_script() - text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=False) + text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=False) assert text.startswith("## Changelog for `v1.2.3`") assert "preview" not in text.lower() assert "**Full Changelog**: v1.2.2...v1.2.3" in text - for commit in COMMITS: - assert f"- {commit}" in text def test_first_release_falls_back_to_the_tag_alone(): module = load_script() - text = module.build_comment("v0.1.0", COMMITS, None, preview=False) + text = module.build_comment("v0.1.0", "- First.", None, preview=False) assert "**Full Changelog**: v0.1.0" in text -def test_empty_commit_range_is_reported(): - module = load_script() - text = module.build_comment("v1.2.3", [], "v1.2.2", preview=False) - - assert "- No changes." in text - - -def test_release_commits_are_filtered_from_the_log(monkeypatch): +def test_get_version_reads_pyproject_without_tomllib(): module = load_script() - log = "Release 1.2.3\nAdd a thing (#1)\n\nBump version to 1.2.3\n" - monkeypatch.setattr( - module.subprocess, - "run", - lambda *a, **k: type("R", (), {"stdout": log, "returncode": 0})(), - ) - - assert module.get_commits_since("v1.2.2") == [ - "Add a thing (#1)", - "Bump version to 1.2.3", - ] + assert module.get_version().count(".") >= 1 -def test_get_version_reads_pyproject_without_tomllib(): +def test_current_version_has_a_changelog_section(): + """Catches a release whose CHANGELOG.md entries were left under Unreleased.""" module = load_script() - assert module.get_version().count(".") >= 1 + changelog = (SCRIPT.parent.parent / "CHANGELOG.md").read_text() + assert module.changelog_section(changelog, module.get_version()) diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 0b0d0aa..d6850e3 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -1,5 +1,6 @@ from __future__ import annotations +from wildedge import constants from wildedge.events import common from wildedge.events.error import ErrorCode, ErrorEvent from wildedge.events.feedback import FeedbackEvent, FeedbackType @@ -77,6 +78,33 @@ def test_feedback_event_enum_and_string_forms(): assert string_event.to_dict()["feedback"]["feedback_type"] == "reject" +def test_error_event_caps_message_and_provider_code(): + event = ErrorEvent( + model_id="m1", + error_code=ErrorCode.UNKNOWN, + error_message="x" * 5000, + provider_error_code="c" * 500, + ).to_dict()["error"] + assert len(event["error_message"]) == constants.ERROR_MSG_MAX_LEN + assert len(event["provider_error_code"]) == constants.PROVIDER_ERROR_CODE_MAX_LEN + + +def test_long_error_message_keeps_start_and_end(): + message = "ffmpeg banner " * 100 + "Error opening input file audio.wav" + sent = ErrorEvent( + model_id="m1", error_code=ErrorCode.UNKNOWN, error_message=message + ).to_dict()["error"]["error_message"] + assert len(sent) == constants.ERROR_MSG_MAX_LEN + assert sent.startswith("ffmpeg banner") + assert sent.endswith("Error opening input file audio.wav") + assert " … " in sent + + +def test_short_error_message_is_unchanged(): + event = ErrorEvent(model_id="m1", error_code="UNKNOWN", error_message="boom") + assert event.to_dict()["error"]["error_message"] == "boom" + + def test_span_event_to_dict_includes_required_fields(): event = SpanEvent( kind="tool", diff --git a/wildedge/constants.py b/wildedge/constants.py index 22ef327..acd0fcc 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -53,7 +53,7 @@ IDLE_POLL_INTERVAL = 5.0 # Error message truncation -ERROR_MSG_MAX_LEN = 200 +ERROR_MSG_MAX_LEN = 500 PROVIDER_ERROR_CODE_MAX_LEN = 64 # Device ID persistence diff --git a/wildedge/decorators.py b/wildedge/decorators.py index 86cbd65..23e90b2 100644 --- a/wildedge/decorators.py +++ b/wildedge/decorators.py @@ -4,7 +4,6 @@ import time from typing import TYPE_CHECKING, Any -from wildedge import constants from wildedge.timing import elapsed_ms if TYPE_CHECKING: @@ -87,7 +86,7 @@ def wrapper(*args, **kwargs): if self.capture_errors: self.handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), **self._correlation, ) raise @@ -106,9 +105,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.capture_errors: self.handle.track_error( error_code="UNKNOWN", - error_message=str(exc_val)[: constants.ERROR_MSG_MAX_LEN] - if exc_val - else None, + error_message=str(exc_val) if exc_val else None, **self._correlation, ) else: diff --git a/wildedge/events/error.py b/wildedge/events/error.py index c5272c8..a3d6bd2 100644 --- a/wildedge/events/error.py +++ b/wildedge/events/error.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Any +from wildedge import constants from wildedge.events.common import add_optional_fields @@ -19,6 +20,29 @@ class ErrorCode(str, Enum): UNKNOWN = "UNKNOWN" +ELLIPSIS = " … " + + +def truncate(value: str | None, limit: int) -> str | None: + return None if value is None else str(value)[:limit] + + +def truncate_message(value: str | None, limit: int) -> str | None: + """Keep the start and the end of a long message. + + Errors put their detail at either end. mlx-whisper prints the ffmpeg banner + first and the failure last, and Hugging Face Hub starts with a request id. + """ + if value is None: + return None + value = str(value) + if len(value) <= limit: + return value + keep = limit - len(ELLIPSIS) + head = keep - keep // 2 + return value[:head] + ELLIPSIS + value[len(value) - keep // 2 :] + + @dataclass class ErrorEvent: model_id: str @@ -49,11 +73,17 @@ def to_dict(self) -> dict: error_data = add_optional_fields( {"error_code": code}, { - "error_message": self.error_message, + # Capped here, so every error event gets the same limit, + # from an integration or a manual track_error() call. + "error_message": truncate_message( + self.error_message, constants.ERROR_MSG_MAX_LEN + ), "stack_trace_hash": self.stack_trace_hash, "related_event_id": self.related_event_id, "http_status": self.http_status, - "provider_error_code": self.provider_error_code, + "provider_error_code": truncate( + self.provider_error_code, constants.PROVIDER_ERROR_CODE_MAX_LEN + ), "duration_ms": self.duration_ms, }, ) diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index f51d8ec..86ffb01 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -196,7 +196,7 @@ def track_api_error( error_code = no_response_error_code(exc) or fallback_code handle.track_error( error_code=error_code, - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), http_status=http_status, provider_error_code=provider_error_code, duration_ms=duration_ms, diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index 2f54d6d..7e4ead8 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants, quantizations +from wildedge import accelerators, quantizations from wildedge.events.inference import ( GenerationConfig, GenerationOutputMeta, @@ -333,7 +333,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/keras.py b/wildedge/integrations/keras.py index f54c8a0..f1f101b 100644 --- a/wildedge/integrations/keras.py +++ b/wildedge/integrations/keras.py @@ -11,7 +11,7 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -99,7 +99,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index 2284329..b78a5e3 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING -from wildedge import constants, quantizations +from wildedge import quantizations from wildedge.events.inference import GenerationOutputMeta, TextInputMeta from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -116,7 +116,7 @@ def patched_call(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise @@ -273,7 +273,7 @@ def patched_generate(model, tokenizer, prompt, *args, **kwargs): # type: ignore if handle is not None: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise finally: diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index 1777709..894aaee 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING, Any -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ( ClassificationOutputMeta, HistogramSummary, @@ -330,7 +330,7 @@ def patched_run(output_names, input_feed, run_options=None): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index c5ed639..114ba4a 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -13,7 +13,7 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -117,7 +117,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise @@ -161,7 +161,7 @@ def patched_predict(*args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index b448978..bdf4a27 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -9,7 +9,7 @@ from collections import Counter from typing import TYPE_CHECKING -from wildedge import accelerators, constants, quantizations +from wildedge import accelerators, quantizations from wildedge.events.inference import ( ClassificationOutputMeta, EmbeddingOutputMeta, @@ -463,7 +463,7 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise finally: diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index 2f5ed8b..397a26e 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ( ClassificationOutputMeta, DetectionOutputMeta, @@ -323,7 +323,7 @@ def patched_call(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise