Repository: posit-dev/posit-sdk-py

Issue:
Content usage wrapper
It would be nice to have a wrapper that lets me get content usage _regardless of the content is Shiny or not_. Currently we have [`/v1/instrumentation/shiny/usage`](https://docs.posit.co/connect/api/#get-/v1/instrumentation/shiny/usage) for shiny usage and [`/v1/instrumentation/content/visits`](https://docs.posit.co/connect/api/#get-/v1/instrumentation/content/visits) for everything else. They contain much of the same information (though there isn't complete overlap!), but it looks like enough that we could return a combined list that has the fields when they are relevant and either doesn't have them or they are marked as `NULL` when they are not relevant.

See: https://github.com/rstudio/connectapi/issues/218

Discussion:
github-actions[bot]: # ☂️ Python Coverage
> current status: ✅
## Overall Coverage
| Lines | Covered | Coverage | Threshold | Status |
| :---: | :-----: | :------: | :-------: | :----: |
|  927  |   859   |    93%   |    80%    |   🟢   |
## New Files
| File                                  | Coverage | Status |
| :------------------------------------ | :------: | :----: |
| src/posit/connect/metrics/__init__.py |    83%   |   🟢   |
| src/posit/connect/metrics/views.py    |    54%   |   🟢   |
| **TOTAL**                             |  **69%** |   🟢   |
## Modified Files
| File                           | Coverage | Status |
| :----------------------------- | :------: | :----: |
| src/posit/connect/client.py    |    99%   |   🟢   |
| src/posit/connect/resources.py |   100%   |   🟢   |
| **TOTAL**                      |  **99%** |   🟢   |


> **updated for commit: `67fbee3` by [action](https://github.com/marketplace/actions/python-coverage)🐍**
nealrichardson: Is this PR intended to solve https://github.com/posit-dev/posit-sdk-py/issues/137?
tdstein: > Is this PR intended to solve #137?

Yes
tdstein: > I'm struggling to read this, the tests aren't making clear to me how I (as a user) should use this and what I do with it.

The main interface is `client.metrics.views` which has fine `.find` and `.find_one`. The returned object is the `ViewEvent` which contains all of the information from the instrumentation APIs.

Patch:
diff --git a/Makefile b/Makefile
index 29c38b2..926c6a3 100644
--- a/Makefile
+++ b/Makefile
@@ -31,11 +31,12 @@ build:
 
 # Target for cleaning up generated files and directories
 clean:
+	rm -rf .coverage .mypy_cache .pytest_cache .ruff_cache *.egg-info build coverage.xml dist htmlcov coverage.xml
 	find . -name "*.egg-info" -exec rm -rf {} +
 	find . -name "*.pyc" -exec rm -f {} +
 	find . -name "__pycache__" -exec rm -rf {} +
 	find . -name "_version.py" -exec rm -rf {} +
-	rm -rf .coverage .mypy_cache .pytest_cache .ruff_cache *.egg-info build coverage.xml dist htmlcov coverage.xml
+	find . -type d -empty -delete
 
 # Target for generating coverage report
 cov:
diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py
index 815c1f3..fff8038 100644
--- a/src/posit/connect/client.py
+++ b/src/posit/connect/client.py
@@ -5,15 +5,15 @@ from __future__ import annotations
 from requests import Response, Session
 from typing import Optional
 
-from . import hooks, me, urls
+from . import config, hooks, me, metrics, urls
 
 from .auth import Auth
 from .config import Config
 from .oauth import OAuthIntegration
 from .content import Content
-from .usage import Usage
+from .metrics.usage import Usage
 from .users import User, Users
-from .visits import Visits
+from .metrics.visits import Visits
 
 
 class Client:
@@ -98,12 +98,30 @@ class Client:
         return Content(config=self.config, session=self.session)
 
     @property
-    def usage(self) -> Usage:
-        return Usage(self.config, self.session)
+    def metrics(self) -> metrics.Metrics:
+        """The Metrics API interface.
 
-    @property
-    def visits(self) -> Visits:
-        return Visits(self.config, self.session)
+        The Metrics API is designed for capturing, retrieving, and managing
+        quantitative measurements of Connect interactions. It is commonly used
+        for monitoring and analyzing system performance, user behavior, and
+        business processes. This API facilitates real-time data collection and
+        accessibility, enabling organizations to make informed decisions based
+        on key performance indicators (KPIs).
+
+        Returns
+        -------
+        metrics.Metrics
+
+        Examples
+        --------
+        >>> from posit import connect
+        >>> client = connect.Client()
+        >>> content_guid = "2243770d-ace0-4782-87f9-fe2aeca14fc8"
+        >>> view_events = client.metrics.views.find(content_guid=content_guid)
+        >>> len(view_events)
+        24
+        """
+        return metrics.Metrics(self.config, self.session)
 
     def __del__(self):
         """Close the session when the Client instance is deleted."""
diff --git a/src/posit/connect/metrics/__init__.py b/src/posit/connect/metrics/__init__.py
new file mode 100644
index 0000000..392bf31
--- /dev/null
+++ b/src/posit/connect/metrics/__init__.py
@@ -0,0 +1,9 @@
+from .. import resources
+
+from . import views
+
+
+class Metrics(resources.Resources):
+    @property
+    def views(self) -> views.Views:
+        return views.Views(self.config, self.session)
diff --git a/src/posit/connect/usage.py b/src/posit/connect/metrics/usage.py
similarity index 97%
rename from src/posit/connect/usage.py
rename to src/posit/connect/metrics/usage.py
index c663b22..25c1a71 100644
--- a/src/posit/connect/usage.py
+++ b/src/posit/connect/metrics/usage.py
@@ -2,10 +2,10 @@ from __future__ import annotations
 
 from typing import List, overload
 
-from . import urls
+from .. import urls
 
-from .cursors import CursorPaginator
-from .resources import Resource, Resources
+from ..cursors import CursorPaginator
+from ..resources import Resource, Resources
 
 
 class UsageEvent(Resource):
diff --git a/src/posit/connect/metrics/views.py b/src/posit/connect/metrics/views.py
new file mode 100644
index 0000000..c33ff5d
--- /dev/null
+++ b/src/posit/connect/metrics/views.py
@@ -0,0 +1,256 @@
+from __future__ import annotations
+
+import itertools
+
+from typing import List, overload
+
+from requests.sessions import Session as Session
+
+from . import usage, visits
+
+from .. import resources
+
+
+class ViewEvent(resources.Resource):
+    @staticmethod
+    def from_event(event: visits.VisitEvent | usage.UsageEvent) -> ViewEvent:
+        if type(event) == visits.VisitEvent:
+            return ViewEvent.from_visit_event(event)
+
+        if type(event) == usage.UsageEvent:
+            return ViewEvent.from_usage_event(event)
+
+        raise TypeError
+
+    @staticmethod
+    def from_visit_event(event: visits.VisitEvent) -> ViewEvent:
+        return ViewEvent(
+            event.config,
+            event.session,
+            content_guid=event.content_guid,
+            user_guid=event.user_guid,
+            variant_key=event.variant_key,
+            rendering_id=event.rendering_id,
+            bundle_id=event.bundle_id,
+            started=event.time,
+            ended=event.time,
+            data_version=event.data_version,
+            path=event.path,
+        )
+
+    @staticmethod
+    def from_usage_event(event: usage.UsageEvent) -> ViewEvent:
+        return ViewEvent(
+            event.config,
+            event.session,
+            content_guid=event.content_guid,
+            user_guid=event.user_guid,
+            started=event.started,
+            ended=event.ended,
+            data_version=event.data_version,
+        )
+
+    def __init__(self, config: resources.Config, session: Session, **kwargs):
+        super().__init__(config, session, **kwargs)
+
+    @property
+    def content_guid(self) -> str:
+        """The associated unique content identifier.
+
+        Returns
+        -------
+        str
+        """
+        return self["content_guid"]
+
+    @property
+    def user_guid(self) -> str:
+        """The associated unique user identifier.
+
+        Returns
+        -------
+        str
+        """
+        return self["user_guid"]
+
+    @property
+    def variant_key(self) -> str | None:
+        """The variant key associated with the visit.
+
+        Returns
+        -------
+        str | None
+            The variant key, or None if the associated content type is static.
+        """
+        return self.get("variant_key")
+
+    @property
+    def rendering_id(self) -> int | None:
+        """The render id associated with the visit.
+
+        Returns
+        -------
+        int | None
+            The render id, or None if the associated content type is static.
+        """
+        return self.get("rendering_id")
+
+    @property
+    def bundle_id(self) -> int | None:
+        """The bundle id associated with the visit.
+
+        Returns
+        -------
+        int
+        """
+        return self.get("bundle_id")
+
+    @property
+    def started(self) -> str:
+        """The visit timestamp.
+
+        Returns
+        -------
+        str
+        """
+        return self["started"]
+
+    @property
+    def ended(self) -> str:
+        """The visit timestamp.
+
+        Returns
+        -------
+        str
+        """
+        return self["ended"]
+
+    @property
+    def data_version(self) -> int:
+        """The data version.
+
+        Returns
+        -------
+        int
+        """
+        return self["data_version"]
+
+    @property
+    def path(self) -> str | None:
+        """The path requested by the user.
+
+        Returns
+        -------
+        str
+        """
+        return self.get("path")
+
+
+class Views(resources.Resources):
+    @overload
+    def find(
+        self,
+        content_guid: str = ...,
+        min_data_version: int = ...,
+        start: str = ...,
+        end: str = ...,
+    ) -> List[ViewEvent]:
+        """Find view events.
+
+        Parameters
+        ----------
+        content_guid : str, optional
+            Filter by an associated unique content identifer, by default ...
+        min_data_version : int, optional
+            Filter by a minimum data version, by default ...
+        start : str, optional
+            Filter by the start time, by default ...
+        end : str, optional
+            Filter by the end time, by default ...
+
+        Returns
+        -------
+        List[ViewEvent]
+        """
+        ...
+
+    @overload
+    def find(self, *args, **kwargs) -> List[ViewEvent]:
+        """Find view events.
+
+        Returns
+        -------
+        List[ViewEvent]
+        """
+        ...
+
+    def find(self, *args, **kwargs) -> List[ViewEvent]:
+        """Find view events.
+
+        Returns
+        -------
+        List[ViewEvent]
+        """
+        events = []
+        finders = (visits.Visits, usage.Usage)
+        for finder in finders:
+            instance = finder(self.config, self.session)
+            events.extend(
+                [
+                    ViewEvent.from_event(event)
+                    for event in instance.find(*args, **kwargs)  # type: ignore[attr-defined]
+                ]
+            )
+        return events
+
+    @overload
+    def find_one(
+        self,
+        content_guid: str = ...,
+        min_data_version: int = ...,
+        start: str = ...,
+        end: str = ...,
+    ) -> ViewEvent | None:
+        """Find a view event.
+
+        Parameters
+        ----------
+        content_guid : str, optional
+            Filter by an associated unique content identifer, by default ...
+        min_data_version : int, optional
+            Filter by a minimum data version, by default ...
+        start : str, optional
+            Filter by the start time, by default ...
+        end : str, optional
+            Filter by the end time, by default ...
+
+        Returns
+        -------
+        Visit | None
+        """
+        ...
+
+    @overload
+    def find_one(self, *args, **kwargs) -> ViewEvent | None:
+        """Find a view event.
+
+        Returns
+        -------
+        Visit | None
+        """
+        ...
+
+    def find_one(self, *args, **kwargs) -> ViewEvent | None:
+        """Find a view event.
+
+        Returns
+        -------
+        ViewEvent | None
+        """
+        finders = (visits.Visits, usage.Usage)
+        for finder in finders:
+            instance = finder(self.config, self.session)
+            event = instance.find_one(*args, **kwargs)  # type: ignore[attr-defined]
+            if event:
+                return ViewEvent.from_event(event)
+        return None
diff --git a/src/posit/connect/visits.py b/src/posit/connect/metrics/visits.py
similarity index 91%
rename from src/posit/connect/visits.py
rename to src/posit/connect/metrics/visits.py
index 4854ac1..5a36f11 100644
--- a/src/posit/connect/visits.py
+++ b/src/posit/connect/metrics/visits.py
@@ -2,13 +2,13 @@ from __future__ import annotations
 
 from typing import List, overload
 
-from . import urls
+from .. import urls
 
-from .cursors import CursorPaginator
-from .resources import Resource, Resources
+from ..cursors import CursorPaginator
+from ..resources import Resource, Resources
 
 
-class Visit(Resource):
+class VisitEvent(Resource):
     @property
     def content_guid(self) -> str:
         """The associated unique content identifier.
@@ -100,7 +100,7 @@ class Visits(Resources):
         min_data_version: int = ...,
         start: str = ...,
         end: str = ...,
-    ) -> List[Visit]:
+    ) -> List[VisitEvent]:
         """Find visits.
 
         Parameters
@@ -121,7 +121,7 @@ class Visits(Resources):
         ...
 
     @overload
-    def find(self, *args, **kwargs) -> List[Visit]:
+    def find(self, *args, **kwargs) -> List[VisitEvent]:
         """Find visits.
 
         Returns
@@ -130,7 +130,7 @@ class Visits(Resources):
         """
         ...
 
-    def find(self, *args, **kwargs) -> List[Visit]:
+    def find(self, *args, **kwargs) -> List[VisitEvent]:
         """Find visits.
 
         Returns
@@ -145,7 +145,7 @@ class Visits(Resources):
         paginator = CursorPaginator(self.session, url, params=params)
         results = paginator.fetch_results()
         return [
-            Visit(
+            VisitEvent(
                 config=self.config,
                 session=self.session,
                 **result,
@@ -160,7 +160,7 @@ class Visits(Resources):
         min_data_version: int = ...,
         start: str = ...,
         end: str = ...,
-    ) -> Visit | None:
+    ) -> VisitEvent | None:
         """Find a visit.
 
         Parameters
@@ -177,12 +177,11 @@ class Visits(Resources):
         Returns
         -------
         Visit | None
-            _description_
         """
         ...
 
     @overload
-    def find_one(self, *args, **kwargs) -> Visit | None:
+    def find_one(self, *args, **kwargs) -> VisitEvent | None:
         """Find a visit.
 
         Returns
@@ -191,7 +190,7 @@ class Visits(Resources):
         """
         ...
 
-    def find_one(self, *args, **kwargs) -> Visit | None:
+    def find_one(self, *args, **kwargs) -> VisitEvent | None:
         """Find a visit.
 
         Returns
@@ -206,7 +205,7 @@ class Visits(Resources):
         pages = paginator.fetch_pages()
         results = (result for page in pages for result in page.results)
         visits = (
-            Visit(
+            VisitEvent(
                 config=self.config,
                 session=self.session,
                 **result,
diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py
index 2667520..b3daf85 100644
--- a/src/posit/connect/resources.py
+++ b/src/posit/connect/resources.py
@@ -1,16 +1,11 @@
-import warnings
-
-from abc import ABC, abstractmethod
-from typing import Any, Generic, List, Optional, TypeVar
+from abc import ABC
+from typing import Any
 
 import requests
 
 from .config import Config
 
 
-T = TypeVar("T")
-
-
 class Resource(ABC, dict):
     def __init__(self, config: Config, session: requests.Session, **kwargs):
         super().__init__(**kwargs)


Test patch:
diff --git a/tests/posit/connect/test_usage.py b/tests/posit/connect/metrics/test_usage.py
similarity index 85%
rename from tests/posit/connect/test_usage.py
rename to tests/posit/connect/metrics/test_usage.py
index 3388f04..f5b1fa9 100644
--- a/tests/posit/connect/test_usage.py
+++ b/tests/posit/connect/metrics/test_usage.py
@@ -1,16 +1,17 @@
 import responses
+import requests
 
 from responses import matchers
 
-from posit.connect import Client
-from posit.connect.usage import UsageEvent, rename_params
+from posit.connect import Client, config
+from posit.connect.metrics import usage
 
-from .api import load_mock  # type: ignore
+from ..api import load_mock  # type: ignore
 
 
 class TestUsageEventAttributes:
     def setup_class(cls):
-        cls.event = UsageEvent(
+        cls.event = usage.UsageEvent(
             None,
             None,
             **load_mock("v1/instrumentation/shiny/usage?limit=500.json")[
@@ -69,15 +70,16 @@ class TestUsageFind:
         )
 
         # setup
-        c = Client("12345", "https://connect.example")
+        c = config.Config("12345", "https://connect.example")
+        session = requests.Session()
 
         # invoke
-        usage = c.usage.find()
+        events = usage.Usage(c, session).find()
 
         # assert
         assert mock_get[0].call_count == 1
         assert mock_get[1].call_count == 1
-        assert len(usage) == 1
+        assert len(events) == 1
 
 
 class TestUsageFindOne:
@@ -113,10 +115,11 @@ class TestUsageFindOne:
         )
 
         # setup
-        c = Client("12345", "https://connect.example")
+        c = config.Config("12345", "https://connect.example")
+        session = requests.Session()
 
         # invoke
-        event = c.usage.find_one()
+        event = usage.Usage(c, session).find_one()
 
         # assert
         assert mock_get[0].call_count == 1
@@ -128,12 +131,12 @@ class TestUsageFindOne:
 class TestRenameParams:
     def test_start_to_from(self):
         params = {"start": ...}
-        params = rename_params(params)
+        params = usage.rename_params(params)
         assert "start" not in params
         assert "from" in params
 
     def test_end_to_to(self):
         params = {"end": ...}
-        params = rename_params(params)
+        params = usage.rename_params(params)
         assert "end" not in params
         assert "to" in params
diff --git a/tests/posit/connect/metrics/test_views.py b/tests/posit/connect/metrics/test_views.py
new file mode 100644
index 0000000..c83094d
--- /dev/null
+++ b/tests/posit/connect/metrics/test_views.py
@@ -0,0 +1,286 @@
+import pytest
+import responses
+
+from responses import matchers
+
+from posit import connect
+from posit.connect.metrics import views, visits, usage
+
+
+from ..api import load_mock  # type: ignore
+
+
+class TestViewEventFromEvent:
+    def test(self):
+        with pytest.raises(TypeError):
+            views.ViewEvent.from_event(None)
+
+
+class TestViewEventFromVisitEvent:
+    def setup_class(cls):
+        visit_event = visits.VisitEvent(
+            None,
+            None,
+            **load_mock("v1/instrumentation/content/visits?limit=500.json")[
+                "results"
+            ][0],
+        )
+        cls.view_event = views.ViewEvent.from_visit_event(visit_event)
+
+    def test_content_guid(self):
+        assert (
+            self.view_event.content_guid
+            == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
+        )
+
+    def test_user_guid(self):
+        assert (
+            self.view_event.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2"
+        )
+
+    def test_variant_key(self):
+        assert self.view_event.variant_key == "HidI2Kwq"
+
+    def test_rendering_id(self):
+        assert self.view_event.rendering_id == 7
+
+    def test_bundle_id(self):
+        assert self.view_event.bundle_id == 33
+
+    def test_started(self):
+        assert self.view_event.started == "2018-09-15T18:00:00-05:00"
+
+    def test_ended(self):
+        assert self.view_event.ended == "2018-09-15T18:00:00-05:00"
+
+    def test_data_version(self):
+        assert self.view_event.data_version == 1
+
+    def test_path(self):
+        assert self.view_event.path == "/logs"
+
+
+class TestViewEventFromUsageEvent:
+    def setup_class(cls):
+        visit_event = usage.UsageEvent(
+            None,
+            None,
+            **load_mock("v1/instrumentation/shiny/usage?limit=500.json")[
+                "results"
+            ][0],
+        )
+        cls.view_event = views.ViewEvent.from_usage_event(visit_event)
+
+    def test_content_guid(self):
+        assert (
+            self.view_event.content_guid
+            == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
+        )
+
+    def test_user_guid(self):
+        assert (
+            self.view_event.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2"
+        )
+
+    def test_variant_key(self):
+        assert self.view_event.variant_key is None
+
+    def test_rendering_id(self):
+        assert self.view_event.rendering_id is None
+
+    def test_bundle_id(self):
+        assert self.view_event.bundle_id is None
+
+    def test_started(self):
+        assert self.view_event.started == "2018-09-15T18:00:00-05:00"
+
+    def test_ended(self):
+        assert self.view_event.ended == "2018-09-15T18:01:00-05:00"
+
+    def test_data_version(self):
+        assert self.view_event.data_version == 1
+
+    def test_path(self):
+        assert self.view_event.path is None
+
+
+class TestViewsFind:
+    @responses.activate
+    def test(self):
+        # behavior
+        mock_get = [None] * 4
+
+        mock_get[0] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/content/visits",
+            json=load_mock("v1/instrumentation/content/visits?limit=500.json"),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[1] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/content/visits",
+            json=load_mock(
+                "v1/instrumentation/content/visits?limit=500&next=23948901087.json"
+            ),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "next": "23948901087",
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[2] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/shiny/usage",
+            json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[3] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/shiny/usage",
+            json=load_mock(
+                "v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"
+            ),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "next": "23948901087",
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        # setup
+        c = connect.Client("12345", "https://connect.example")
+
+        # invoke
+        events = c.metrics.views.find()
+
+        # assert
+        assert mock_get[0].call_count == 1
+        assert mock_get[1].call_count == 1
+        assert mock_get[2].call_count == 1
+        assert mock_get[3].call_count == 1
+        assert len(events) == 2
+
+
+class TestViewsFindOne:
+    @responses.activate
+    def test(self):
+        # behavior
+        mock_get = [None] * 4
+
+        mock_get[0] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/content/visits",
+            json=load_mock("v1/instrumentation/content/visits?limit=500.json"),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[1] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/content/visits",
+            json=load_mock(
+                "v1/instrumentation/content/visits?limit=500&next=23948901087.json"
+            ),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "next": "23948901087",
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[2] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/shiny/usage",
+            json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        mock_get[3] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/shiny/usage",
+            json=load_mock(
+                "v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"
+            ),
+            match=[
+                matchers.query_param_matcher(
+                    {
+                        "next": "23948901087",
+                        "limit": 500,
+                    }
+                )
+            ],
+        )
+
+        # setup
+        c = connect.Client("12345", "https://connect.example")
+
+        # invoke
+        view_event = c.metrics.views.find_one()
+
+        # assert
+        assert mock_get[0].call_count == 1
+        assert mock_get[1].call_count == 0
+        assert mock_get[2].call_count == 0
+        assert mock_get[3].call_count == 0
+        assert view_event
+        assert (
+            view_event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
+        )
+
+    @responses.activate
+    def test_none(self):
+        # behavior
+        mock_get = [None] * 2
+
+        # return an empty result set to push through the iterator
+        mock_get[0] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/content/visits",
+            json=load_mock(
+                "v1/instrumentation/content/visits?limit=500&next=23948901087.json"
+            ),
+        )
+
+        mock_get[1] = responses.get(
+            f"https://connect.example/__api__/v1/instrumentation/shiny/usage",
+            json=load_mock(
+                "v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"
+            ),
+        )
+
+        # setup
+        c = connect.Client("12345", "https://connect.example")
+
+        # invoke
+        view_event = c.metrics.views.find_one(content_guid="not-found")
+
+        # assert
+        assert mock_get[0].call_count == 1
+        assert mock_get[1].call_count == 1
+        assert view_event is None
diff --git a/tests/posit/connect/test_visits.py b/tests/posit/connect/metrics/test_visits.py
similarity index 83%
rename from tests/posit/connect/test_visits.py
rename to tests/posit/connect/metrics/test_visits.py
index b5288fd..11cf450 100644
--- a/tests/posit/connect/test_visits.py
+++ b/tests/posit/connect/metrics/test_visits.py
@@ -1,16 +1,17 @@
 import responses
+import requests
 
 from responses import matchers
 
-from posit.connect import Client
-from posit.connect.visits import Visit, rename_params
+from posit.connect import Client, config
+from posit.connect.metrics import visits
 
-from .api import load_mock  # type: ignore
+from ..api import load_mock  # type: ignore
 
 
 class TestVisitAttributes:
     def setup_class(cls):
-        cls.visit = Visit(
+        cls.visit = visits.VisitEvent(
             None,
             None,
             **load_mock("v1/instrumentation/content/visits?limit=500.json")[
@@ -78,15 +79,16 @@ class TestVisitsFind:
         )
 
         # setup
-        c = Client("12345", "https://connect.example")
+        c = config.Config("12345", "https://connect.example")
+        session = requests.Session()
 
         # invoke
-        visits = c.visits.find()
+        events = visits.Visits(c, session).find()
 
         # assert
         assert mock_get[0].call_count == 1
         assert mock_get[1].call_count == 1
-        assert len(visits) == 1
+        assert len(events) == 1
 
 
 class TestVisitsFindOne:
@@ -122,27 +124,28 @@ class TestVisitsFindOne:
         )
 
         # setup
-        c = Client("12345", "https://connect.example")
+        c = config.Config("12345", "https://connect.example")
+        session = requests.Session()
 
         # invoke
-        visit = c.visits.find_one()
+        event = visits.Visits(c, session).find_one()
 
         # assert
         assert mock_get[0].call_count == 1
         assert mock_get[1].call_count == 0
-        assert visit
-        assert visit.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
+        assert event
+        assert event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
 
 
 class TestRenameParams:
     def test_start_to_from(self):
         params = {"start": ...}
-        params = rename_params(params)
+        params = visits.rename_params(params)
         assert "start" not in params
         assert "from" in params
 
     def test_end_to_to(self):
         params = {"end": ...}
-        params = rename_params(params)
+        params = visits.rename_params(params)
         assert "end" not in params
         assert "to" in params