-
-
Notifications
You must be signed in to change notification settings - Fork 16
Instrument skill handlers and dialog rendering #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
goldyfruit
wants to merge
4
commits into
OpenVoiceOS:dev
Choose a base branch
from
goldyfruit:perf/runtime-handler-metrics
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| # Skill Runtime Performance Metrics | ||
|
|
||
| `ovos-workshop` contributes two process-local histograms to a compatible | ||
| `ovos-core` metrics endpoint through the `ovos.performance.metrics` entry-point | ||
| group: | ||
|
|
||
| | Metric | Boundary | | ||
| |---|---| | ||
| | `ovos_skill_handler_execution_seconds` | A registered skill handler with lifecycle metadata, including nested service calls and dialog work | | ||
| | `ovos_dialog_render_seconds` | Mustache dialog rendering performed by `speak_dialog` or a `get_response` retry | | ||
|
|
||
| Handler duration intentionally contains nested service-call and dialog-render | ||
| duration. These histograms explain a request hierarchically and must not be | ||
| summed as disjoint stages. | ||
|
|
||
| Internal bus callbacks registered without handler lifecycle metadata are not | ||
| counted as skill handlers. This keeps bus housekeeping from polluting the stage | ||
| that operators use to explain user-visible reply latency. | ||
|
|
||
| The histograms are fixed-cardinality and process-local. They do not contain | ||
| skill IDs, session IDs, utterances, or other user-controlled labels. Prometheus | ||
| should scrape each runtime process and aggregate the cumulative buckets before | ||
| calculating p50 or p95. | ||
|
|
||
| `ovos-workshop` does not open an HTTP port itself. Endpoint ownership remains in | ||
| `ovos-core`, so standalone skills do not unexpectedly expose a listener. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| """Process-local latency histograms for skill execution and dialog rendering.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import math | ||
| import time | ||
| from collections.abc import Iterable, Iterator, Mapping | ||
| from contextlib import contextmanager | ||
| from threading import Lock | ||
| from typing import Any | ||
|
|
||
| DEFAULT_BUCKETS_MS = ( | ||
| 1.0, | ||
| 2.5, | ||
| 5.0, | ||
| 10.0, | ||
| 25.0, | ||
| 50.0, | ||
| 100.0, | ||
| 250.0, | ||
| 500.0, | ||
| 1_000.0, | ||
| 2_500.0, | ||
| 5_000.0, | ||
| 10_000.0, | ||
| 30_000.0, | ||
| ) | ||
|
|
||
|
|
||
| class LatencyHistogram: | ||
| """Thread-safe cumulative latency histogram with fixed buckets.""" | ||
|
|
||
| def __init__(self, name: str, *, | ||
| buckets_ms: Iterable[float] = DEFAULT_BUCKETS_MS) -> None: | ||
| self.name = name | ||
| self._bounds = tuple(sorted(float(value) for value in buckets_ms)) | ||
| self._buckets = [0] * len(self._bounds) | ||
| self._count = 0 | ||
| self._sum_ms = 0.0 | ||
| self._lock = Lock() | ||
|
|
||
| def observe_ms(self, elapsed_ms: float) -> None: | ||
| """Record one finite, non-negative duration in milliseconds.""" | ||
| value = float(elapsed_ms) | ||
| if not math.isfinite(value): | ||
| raise ValueError("elapsed_ms must be finite") | ||
| value = max(0.0, value) | ||
| with self._lock: | ||
| self._count += 1 | ||
| self._sum_ms += value | ||
| for index, bound in enumerate(self._bounds): | ||
| if value <= bound: | ||
| self._buckets[index] += 1 | ||
|
|
||
| @contextmanager | ||
| def measure(self) -> Iterator[None]: | ||
| """Observe the enclosed block, including exceptional exits.""" | ||
| started = time.monotonic() | ||
| try: | ||
| yield | ||
| finally: | ||
| self.observe_ms((time.monotonic() - started) * 1_000) | ||
|
|
||
| def snapshot(self) -> Mapping[str, Any]: | ||
| """Return an immutable, JSON-friendly cumulative snapshot.""" | ||
| with self._lock: | ||
| buckets = { | ||
| f"le_{bound:g}": count | ||
| for bound, count in zip(self._bounds, self._buckets) | ||
| } | ||
| buckets["inf"] = self._count | ||
| return { | ||
| "name": self.name, | ||
| "count": self._count, | ||
| "sum_ms": self._sum_ms, | ||
| "buckets": buckets, | ||
| } | ||
|
|
||
|
|
||
| SKILL_HANDLER = LatencyHistogram("ovos_skill_handler_execution_ms") | ||
| DIALOG_RENDER = LatencyHistogram("ovos_dialog_render_ms") | ||
|
|
||
|
|
||
| def performance_histograms() -> Mapping[str, Mapping[str, Any]]: | ||
| """Return the process-local Workshop runtime histograms.""" | ||
| return { | ||
| histogram.name: histogram.snapshot() | ||
| for histogram in (SKILL_HANDLER, DIALOG_RENDER) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """Runtime metric coverage for skill handlers and dialog rendering.""" | ||
|
|
||
| from unittest.mock import MagicMock, PropertyMock, patch | ||
|
|
||
| from ovos_bus_client.message import Message | ||
| from ovos_utils.events import EventContainer | ||
| from ovos_utils.fakebus import FakeBus | ||
|
|
||
| from ovos_workshop._metrics import DIALOG_RENDER, SKILL_HANDLER | ||
| from ovos_workshop.skills.ovos import OVOSSkill | ||
|
|
||
|
|
||
| def _skill() -> OVOSSkill: | ||
| skill = OVOSSkill.__new__(OVOSSkill) | ||
| skill.skill_id = "test.skill" | ||
| skill.bus = FakeBus() | ||
| skill.events = EventContainer(skill.bus) | ||
| skill.log = MagicMock() | ||
| skill._on_event_start = MagicMock() | ||
| skill._on_event_end = MagicMock() | ||
| skill._on_event_error = MagicMock() | ||
| return skill | ||
|
|
||
|
|
||
| def test_handler_info_events_measure_handler_execution(): | ||
| skill = _skill() | ||
| calls = MagicMock() | ||
|
|
||
| def handler(message): | ||
| calls(message) | ||
|
|
||
| before = SKILL_HANDLER.snapshot()["count"] | ||
| skill.add_event( | ||
| "test.intent", | ||
| handler, | ||
| handler_info="mycroft.skill.handler", | ||
| is_intent=True, | ||
| ) | ||
|
|
||
| message = Message("test.intent") | ||
| skill.bus.emit(message) | ||
|
|
||
| calls.assert_called_once_with(message) | ||
| assert SKILL_HANDLER.snapshot()["count"] == before + 1 | ||
|
|
||
|
|
||
| def test_internal_events_do_not_pollute_skill_handler_metric(): | ||
| skill = _skill() | ||
| before = SKILL_HANDLER.snapshot()["count"] | ||
|
|
||
| def handler(_message): | ||
| return None | ||
|
|
||
| skill.add_event("internal.event", handler) | ||
|
|
||
| skill.bus.emit(Message("internal.event")) | ||
|
|
||
| assert SKILL_HANDLER.snapshot()["count"] == before | ||
|
|
||
|
|
||
| def test_speak_dialog_measures_only_renderer_work(): | ||
| skill = _skill() | ||
| renderer = MagicMock() | ||
| renderer.render.return_value = "It is sunny." | ||
| skill.speak = MagicMock() | ||
| before = DIALOG_RENDER.snapshot()["count"] | ||
|
|
||
| with patch.object( | ||
| OVOSSkill, | ||
| "dialog_renderer", | ||
| new_callable=PropertyMock, | ||
| return_value=renderer, | ||
| ): | ||
| skill.speak_dialog("weather.answer", {"summary": "sunny"}) | ||
|
|
||
| renderer.render.assert_called_once_with( | ||
| "weather.answer", {"summary": "sunny"} | ||
| ) | ||
| skill.speak.assert_called_once() | ||
| assert DIALOG_RENDER.snapshot()["count"] == before + 1 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have some doubts on this one, maybe we need metrics helpers in ovos-utils to use across all other packages? we have the StopWatch class in there already.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed—the exporter is already provider/entry-point driven, but the low-level monotonic histogram storage should be shared instead of copied. I extracted that dependency-free primitive to OpenVoiceOS/ovos-utils#416. Workshop cannot import Core without reversing the dependency direction, and I do not want to hide the coordination behind a fallback/getattr. The intended order is: review and release #416, then replace this PR’s local helper and bump the explicit ovos-utils minimum. Metric ownership stays in Workshop; scrape/export policy stays in the host runtime. I am leaving this thread unresolved until that prerequisite and consumer rebase are complete.