Skip to content

Commit dc6bbb5

Browse files
toph-allentdstein
andauthored
feat: add metrics.hits, with support for new instrumentation/content/hits endpoint (#410)
## Intent Add support support for Posit Connect's content hits endpoint (`GET /v1/instrumentation/content/hits`) Closes #403 ## Notes - Implementation lives in `metrics/hits.py` - `from` and `to` parameters are named `start` and `end` in Python to work around reserved keywords. The `rename_params` function shared across all metrics classes has been collapsed into a single function. ## Tests - Tests have been added which cover all of the new functionality. --------- Co-authored-by: Taylor Steinberg <taylor@steinberg.xyz>
1 parent 8add419 commit dc6bbb5

File tree

11 files changed

+299
-79
lines changed

11 files changed

+299
-79
lines changed

src/posit/connect/metrics/hits.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
from typing_extensions import (
4+
Iterable,
5+
Protocol,
6+
)
7+
8+
from ..resources import Resource, ResourceSequence, _ResourceSequence
9+
from .rename_params import rename_params
10+
11+
12+
class Hit(Resource, Protocol):
13+
pass
14+
15+
16+
class Hits(ResourceSequence[Hit], Protocol):
17+
def fetch(
18+
self,
19+
*,
20+
start: str = ...,
21+
end: str = ...,
22+
) -> Iterable[Hit]:
23+
"""
24+
Fetch all content hit records matching the specified conditions.
25+
26+
Parameters
27+
----------
28+
start : str, not required
29+
The timestamp that starts the time window of interest in RFC 3339 format.
30+
Any hit information that happened prior to this timestamp will not be returned.
31+
Example: "2025-05-01T00:00:00Z"
32+
end : str, not required
33+
The timestamp that ends the time window of interest in RFC 3339 format.
34+
Any hit information that happened after this timestamp will not be returned.
35+
Example: "2025-05-02T00:00:00Z"
36+
37+
Returns
38+
-------
39+
Iterable[Hit]
40+
All content hit records matching the specified conditions.
41+
"""
42+
...
43+
44+
def find_by(
45+
self,
46+
*,
47+
id: str = ..., # noqa: A002
48+
content_guid: str = ...,
49+
user_guid: str = ...,
50+
timestamp: str = ...,
51+
) -> Hit | None:
52+
"""
53+
Find the first hit record matching the specified conditions.
54+
55+
There is no implied ordering, so if order matters, you should specify it yourself.
56+
57+
Parameters
58+
----------
59+
id : str, not required
60+
The ID of the activity record.
61+
content_guid : str, not required
62+
The GUID, in RFC4122 format, of the content this information pertains to.
63+
user_guid : str, not required
64+
The GUID, in RFC4122 format, of the user that visited the content.
65+
May be null when the target content does not require a user session.
66+
timestamp : str, not required
67+
The timestamp, in RFC 3339 format, when the user visited the content.
68+
69+
Returns
70+
-------
71+
Hit | None
72+
The first hit record matching the specified conditions, or `None` if no such record exists.
73+
"""
74+
...
75+
76+
77+
class _Hits(_ResourceSequence, Hits):
78+
def fetch(
79+
self,
80+
**kwargs,
81+
) -> Iterable[Hit]:
82+
"""
83+
Fetch all content hit records matching the specified conditions.
84+
85+
Parameters
86+
----------
87+
start : str, not required
88+
The timestamp that starts the time window of interest in RFC 3339 format.
89+
Any hit information that happened prior to this timestamp will not be returned.
90+
This corresponds to the `from` parameter in the API.
91+
Example: "2025-05-01T00:00:00Z"
92+
end : str, not required
93+
The timestamp that ends the time window of interest in RFC 3339 format.
94+
Any hit information that happened after this timestamp will not be returned.
95+
This corresponds to the `to` parameter in the API.
96+
Example: "2025-05-02T00:00:00Z"
97+
98+
Returns
99+
-------
100+
Iterable[Hit]
101+
All content hit records matching the specified conditions.
102+
"""
103+
params = rename_params(kwargs)
104+
return super().fetch(**params)

src/posit/connect/metrics/metrics.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Metric resources."""
22

33
from .. import resources
4+
from ..context import requires
5+
from .hits import Hits, _Hits
46
from .usage import Usage
57

68

@@ -16,3 +18,8 @@ class Metrics(resources.Resources):
1618
@property
1719
def usage(self) -> Usage:
1820
return Usage(self._ctx)
21+
22+
@property
23+
@requires(version="2025.04.0")
24+
def hits(self) -> Hits:
25+
return _Hits(self._ctx, "v1/instrumentation/content/hits", uid="id")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
def rename_params(params: dict) -> dict:
2+
"""Rename params from the internal to the external signature.
3+
4+
The API accepts `from` as a querystring parameter. Since `from` is a reserved word in Python, the SDK uses the name `start` instead. The querystring parameter `to` takes the same form for consistency.
5+
6+
Parameters
7+
----------
8+
params : dict
9+
10+
Returns
11+
-------
12+
dict
13+
"""
14+
if "start" in params:
15+
params["from"] = params["start"]
16+
del params["start"]
17+
18+
if "end" in params:
19+
params["to"] = params["end"]
20+
del params["end"]
21+
22+
return params

src/posit/connect/metrics/shiny_usage.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from ..cursors import CursorPaginator
66
from ..resources import BaseResource, Resources
7+
from .rename_params import rename_params
78

89

910
class ShinyUsageEvent(BaseResource):
@@ -171,27 +172,3 @@ def find_one(self, **kwargs) -> ShinyUsageEvent | None:
171172
for result in results
172173
)
173174
return next(visits, None)
174-
175-
176-
def rename_params(params: dict) -> dict:
177-
"""Rename params from the internal to the external signature.
178-
179-
The API accepts `from` as a querystring parameter. Since `from` is a reserved word in Python, the SDK uses the name `start` instead. The querystring parameter `to` takes the same form for consistency.
180-
181-
Parameters
182-
----------
183-
params : dict
184-
185-
Returns
186-
-------
187-
dict
188-
"""
189-
if "start" in params:
190-
params["from"] = params["start"]
191-
del params["start"]
192-
193-
if "end" in params:
194-
params["to"] = params["end"]
195-
del params["end"]
196-
197-
return params

src/posit/connect/metrics/visits.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from ..cursors import CursorPaginator
66
from ..resources import BaseResource, Resources
7+
from .rename_params import rename_params
78

89

910
class VisitEvent(BaseResource):
@@ -203,27 +204,3 @@ def find_one(self, **kwargs) -> VisitEvent | None:
203204
for result in results
204205
)
205206
return next(visits, None)
206-
207-
208-
def rename_params(params: dict) -> dict:
209-
"""Rename params from the internal to the external signature.
210-
211-
The API accepts `from` as a querystring parameter. Since `from` is a reserved word in Python, the SDK uses the name `start` instead. The querystring parameter `to` takes the same form for consistency.
212-
213-
Parameters
214-
----------
215-
params : dict
216-
217-
Returns
218-
-------
219-
dict
220-
"""
221-
if "start" in params:
222-
params["from"] = params["start"]
223-
del params["start"]
224-
225-
if "end" in params:
226-
params["to"] = params["end"]
227-
del params["end"]
228-
229-
return params

src/posit/connect/resources.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def create(self, **attributes: Any) -> Any:
136136
response = self._ctx.client.post(self._path, json=attributes)
137137
result = response.json()
138138
uid = result[self._uid]
139-
path = posixpath.join(self._path, uid)
139+
path = posixpath.join(self._path, str(uid))
140140
return _Resource(self._ctx, path, **result)
141141

142142
def fetch(self, **conditions) -> Iterable[Any]:
@@ -145,7 +145,7 @@ def fetch(self, **conditions) -> Iterable[Any]:
145145
resources = []
146146
for result in results:
147147
uid = result[self._uid]
148-
path = posixpath.join(self._path, uid)
148+
path = posixpath.join(self._path, str(uid))
149149
resource = _Resource(self._ctx, path, **result)
150150
resources.append(resource)
151151

@@ -188,7 +188,7 @@ def fetch(self, **conditions):
188188
results = page.results
189189
for result in results:
190190
uid = result[self._uid]
191-
path = posixpath.join(self._path, uid)
191+
path = posixpath.join(self._path, str(uid))
192192
resource = _Resource(self._ctx, path, **result)
193193
resources.append(resource)
194194
yield from resources
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[
2+
{
3+
"id": 1001,
4+
"content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3",
5+
"user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2",
6+
"timestamp": "2025-05-01T10:00:00-05:00",
7+
"data": {
8+
"path": "/dashboard",
9+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
10+
}
11+
},
12+
{
13+
"id": 1002,
14+
"content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3",
15+
"user_guid": "a5e2b41d-3f8e-47f2-9955-f05ea3b0d5c3",
16+
"timestamp": "2025-05-01T10:05:00-05:00",
17+
"data": {
18+
"path": "/dashboard/details",
19+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
20+
}
21+
}
22+
]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Tests for the hits metrics module."""
2+
3+
import pytest
4+
import responses
5+
from responses import matchers
6+
7+
from posit import connect
8+
9+
from ..api import load_mock
10+
11+
12+
class TestHitsFetch:
13+
@responses.activate
14+
def test_fetch(self):
15+
# Set up mock response
16+
mock_get = responses.get(
17+
"https://connect.example/__api__/v1/instrumentation/content/hits",
18+
json=load_mock("v1/instrumentation/content/hits.json"),
19+
)
20+
21+
# Create client with required version for hits API
22+
c = connect.Client("https://connect.example", "12345")
23+
c._ctx.version = "2025.04.0"
24+
25+
# Fetch hits
26+
hits = list(c.metrics.hits.fetch())
27+
28+
# Verify request was made
29+
assert mock_get.call_count == 1
30+
31+
# Verify results
32+
assert len(hits) == 2
33+
assert hits[0]["id"] == 1001
34+
assert hits[0]["content_guid"] == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
35+
assert hits[0]["timestamp"] == "2025-05-01T10:00:00-05:00"
36+
assert hits[0]["data"]["path"] == "/dashboard"
37+
38+
@responses.activate
39+
def test_fetch_with_params(self):
40+
# Set up mock response
41+
mock_get = responses.get(
42+
"https://connect.example/__api__/v1/instrumentation/content/hits",
43+
json=load_mock("v1/instrumentation/content/hits.json"),
44+
match=[
45+
matchers.query_param_matcher(
46+
{
47+
"from": "2025-05-01T00:00:00Z",
48+
"to": "2025-05-02T00:00:00Z",
49+
}
50+
),
51+
],
52+
)
53+
54+
# Create client with required version for hits API
55+
c = connect.Client("https://connect.example", "12345")
56+
c._ctx.version = "2025.04.0"
57+
58+
# Fetch hits with parameters
59+
hits = list(
60+
c.metrics.hits.fetch(**{"from": "2025-05-01T00:00:00Z", "to": "2025-05-02T00:00:00Z"})
61+
)
62+
63+
# Verify request was made with proper parameters
64+
assert mock_get.call_count == 1
65+
66+
# Verify results
67+
assert len(hits) == 2
68+
69+
70+
class TestHitsFindBy:
71+
@responses.activate
72+
def test_find_by(self):
73+
# Set up mock response
74+
mock_get = responses.get(
75+
"https://connect.example/__api__/v1/instrumentation/content/hits",
76+
json=load_mock("v1/instrumentation/content/hits.json"),
77+
)
78+
79+
# Create client with required version for hits API
80+
c = connect.Client("https://connect.example", "12345")
81+
c._ctx.version = "2025.04.0"
82+
83+
# Find hits by content_guid
84+
hit = c.metrics.hits.find_by(content_guid="bd1d2285-6c80-49af-8a83-a200effe3cb3")
85+
86+
# Verify request was made
87+
assert mock_get.call_count == 1
88+
89+
# Verify results
90+
assert hit is not None
91+
assert hit["id"] == 1001
92+
assert hit["content_guid"] == "bd1d2285-6c80-49af-8a83-a200effe3cb3"
93+
94+
@responses.activate
95+
def test_find_by_not_found(self):
96+
# Set up mock response
97+
mock_get = responses.get(
98+
"https://connect.example/__api__/v1/instrumentation/content/hits",
99+
json=load_mock("v1/instrumentation/content/hits.json"),
100+
)
101+
102+
# Create client with required version for hits API
103+
c = connect.Client("https://connect.example", "12345")
104+
c._ctx.version = "2025.04.0"
105+
106+
# Try to find hit with non-existent content_guid
107+
hit = c.metrics.hits.find_by(content_guid="non-existent-guid")
108+
109+
# Verify request was made
110+
assert mock_get.call_count == 1
111+
112+
# Verify no result was found
113+
assert hit is None
114+
115+
116+
class TestHitsVersionRequirement:
117+
@responses.activate
118+
def test_version_requirement(self):
119+
# Create client with version that's too old
120+
c = connect.Client("https://connect.example", "12345")
121+
c._ctx.version = "2024.04.0"
122+
123+
with pytest.raises(RuntimeError):
124+
h = c.metrics.hits

0 commit comments

Comments
 (0)