Skip to content

Commit 61700c7

Browse files
[monitorlib] Record time required for query authorization (#1414)
1 parent e59f147 commit 61700c7

File tree

6 files changed

+88
-29
lines changed

6 files changed

+88
-29
lines changed

monitoring/monitorlib/fetch/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@
77
from dataclasses import dataclass
88
from enum import Enum
99
from http.client import RemoteDisconnected
10-
from typing import Self, TypeVar
10+
from typing import Optional, Self, TypeVar
1111
from urllib.parse import urlparse
1212

1313
import flask
1414
import jwt
1515
import requests
1616
import urllib3
17-
from implicitdict import ImplicitDict, Optional, StringBasedDateTime
17+
from implicitdict import (
18+
ImplicitDict,
19+
StringBasedDateTime,
20+
StringBasedTimeDelta,
21+
)
1822
from loguru import logger
1923

2024
from monitoring.monitorlib import infrastructure
2125
from monitoring.monitorlib.errors import stacktrace_string
26+
from monitoring.monitorlib.infrastructure import AUTHORIZATION_DT
2227
from monitoring.monitorlib.rid import RIDVersion
2328

2429

@@ -54,6 +59,9 @@ class RequestDescription(ImplicitDict):
5459
initiated_at: Optional[StringBasedDateTime]
5560
received_at: Optional[StringBasedDateTime]
5661

62+
auth_dt: Optional[StringBasedTimeDelta]
63+
"""Amount of time required to obtain authorization before performing the primary query (de minimus or unknown by default)."""
64+
5765
def __init__(self, *args, **kwargs):
5866
super().__init__(*args, **kwargs)
5967
if "headers" not in self:
@@ -124,6 +132,11 @@ def describe_request(
124132
"initiated_at": StringBasedDateTime(initiated_at),
125133
"headers": headers,
126134
}
135+
authorization_dt: datetime.timedelta | None = getattr(req, AUTHORIZATION_DT, None)
136+
if authorization_dt:
137+
kwargs["auth_dt"] = StringBasedTimeDelta(
138+
f"{authorization_dt.total_seconds():.4g}s"
139+
)
127140
body = req.body.decode("utf-8") if req.body else None
128141
try:
129142
if body:

monitoring/monitorlib/infrastructure.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
import urllib.parse
99
import weakref
10+
from dataclasses import dataclass
1011
from enum import Enum
1112

1213
import jwt
@@ -28,11 +29,20 @@
2829
CLIENT_TIMEOUT = 10 # seconds
2930
SOCKET_KEEP_ALIVE_LIMIT = 57 # seconds.
3031

32+
AUTHORIZATION_DT = "authorization_dt"
33+
"""This attribute may be added to a PreparedRequest indicating the timedelta required to obtain authorization"""
34+
3135

3236
AuthSpec = str
3337
"""Specification for means by which to obtain access tokens."""
3438

3539

40+
@dataclass
41+
class AdditionalHeaders:
42+
headers: dict[str, str]
43+
token_issuance_seconds: float | None = None
44+
45+
3646
class AuthAdapter:
3747
"""Base class for an adapter that add JWTs to requests."""
3848

@@ -44,33 +54,48 @@ def issue_token(self, intended_audience: str, scopes: list[str]) -> str:
4454

4555
raise NotImplementedError()
4656

47-
def get_headers(self, url: str, scopes: list[str] | None = None) -> dict[str, str]:
57+
def get_headers(
58+
self, url: str, scopes: list[str] | None = None
59+
) -> AdditionalHeaders:
4860
if scopes is None:
4961
scopes = ALL_SCOPES
5062
scopes = [s.value if isinstance(s, Enum) else s for s in scopes]
5163
intended_audience = urllib.parse.urlparse(url).hostname
5264

5365
if not intended_audience:
54-
return {}
66+
return AdditionalHeaders(headers={})
5567

5668
scope_string = " ".join(scopes)
5769
if intended_audience not in self._tokens:
5870
self._tokens[intended_audience] = {}
5971
if scope_string not in self._tokens[intended_audience]:
72+
t0 = time.monotonic()
6073
token = self.issue_token(intended_audience, scopes)
74+
dt_s = time.monotonic() - t0
6175
else:
6276
token = self._tokens[intended_audience][scope_string]
77+
dt_s = None
6378
payload = jwt.decode(token, options={"verify_signature": False})
6479
expires = EPOCH + datetime.timedelta(seconds=payload["exp"])
6580
if datetime.datetime.now(datetime.UTC) > expires - TOKEN_REFRESH_MARGIN:
81+
t0 = time.monotonic()
6682
token = self.issue_token(intended_audience, scopes)
83+
dt_s = (dt_s or 0) + (time.monotonic() - t0)
6784
self._tokens[intended_audience][scope_string] = token
68-
return {"Authorization": "Bearer " + token}
85+
return AdditionalHeaders(
86+
headers={"Authorization": "Bearer " + token}, token_issuance_seconds=dt_s
87+
)
6988

70-
def add_headers(self, request: requests.PreparedRequest, scopes: list[str]):
89+
def add_headers(
90+
self, request: requests.PreparedRequest, scopes: list[str]
91+
) -> AdditionalHeaders:
7192
if request.url:
72-
for k, v in self.get_headers(request.url, scopes).items():
93+
additional_headers = self.get_headers(request.url, scopes)
94+
for k, v in additional_headers.headers.items():
7395
request.headers[k] = v
96+
return additional_headers
97+
else:
98+
return AdditionalHeaders(headers={})
7499

75100
def get_sub(self) -> str | None:
76101
"""Retrieve `sub` claim from one of the existing tokens"""
@@ -182,6 +207,21 @@ def prepare_request(self, request, **kwargs):
182207

183208
return super().prepare_request(request, **kwargs)
184209

210+
def add_auth(
211+
self, prepared_request: requests.PreparedRequest, scopes: list[str] | None
212+
) -> requests.PreparedRequest:
213+
if scopes and self.auth_adapter:
214+
additional_headers = self.auth_adapter.add_headers(prepared_request, scopes)
215+
if additional_headers.token_issuance_seconds:
216+
setattr(
217+
prepared_request,
218+
AUTHORIZATION_DT,
219+
datetime.timedelta(
220+
seconds=additional_headers.token_issuance_seconds
221+
),
222+
)
223+
return prepared_request
224+
185225
def adjust_request_kwargs(self, kwargs):
186226
if self.auth_adapter:
187227
scopes = None
@@ -194,14 +234,7 @@ def adjust_request_kwargs(self, kwargs):
194234
if scopes is None:
195235
scopes = self.default_scopes
196236

197-
def auth(
198-
prepared_request: requests.PreparedRequest,
199-
) -> requests.PreparedRequest:
200-
if scopes and self.auth_adapter:
201-
self.auth_adapter.add_headers(prepared_request, scopes)
202-
return prepared_request
203-
204-
kwargs["auth"] = auth
237+
kwargs["auth"] = lambda req: self.add_auth(req, scopes)
205238
if "timeout" not in kwargs:
206239
kwargs["timeout"] = self.timeout_seconds
207240
return kwargs
@@ -295,10 +328,8 @@ def adjust_request_kwargs(self, url, method, kwargs):
295328
raise ValueError(
296329
"All tests must specify auth scope for all session requests. Either specify as an argument for each individual HTTP call, or decorate the test with @default_scope."
297330
)
298-
headers = {}
299-
for k, v in self.auth_adapter.get_headers(url, scopes).items():
300-
headers[k] = v
301-
kwargs["headers"] = headers
331+
additional_headers = self.auth_adapter.get_headers(url, scopes)
332+
kwargs["headers"] = additional_headers.headers
302333
if method == "PUT" and kwargs.get("data"):
303334
kwargs["json"] = kwargs["data"]
304335
del kwargs["data"]

monitoring/uss_qualifier/resources/communications/client_identity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def subject(self) -> str:
5555
# we force one using the client identify audience and scopes
5656

5757
# Trigger a caching initial token request so that adapter.get_sub() will return something
58-
headers = self._adapter.get_headers(
58+
additional_headers = self._adapter.get_headers(
5959
f"https://{self.specification.whoami_audience}",
6060
[self.specification.whoami_scope],
6161
)
@@ -66,7 +66,7 @@ def subject(self) -> str:
6666
raise ValueError(
6767
f"subject is None, meaning `sub` claim was not found in payload of token, "
6868
f"using {type(self._adapter).__name__} requesting {self.specification.whoami_scope} scope "
69-
f"for {self.specification.whoami_audience} audience: {headers['Authorization'][len('Bearer: ') :]}"
69+
f"for {self.specification.whoami_audience} audience: {additional_headers.headers['Authorization'][len('Bearer: ') :]}"
7070
)
7171

7272
return sub

monitoring/uss_qualifier/scenarios/astm/netrid/common/dss/heavy_traffic_concurrent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ async def _get_isa(self, isa_id):
206206
"GET",
207207
url,
208208
)
209+
# TODO: Do not rely on a prepared request that is not actually used in order to create the Query RequestDescription; instead build it from the request actually made
209210
prep = self._dss.client.prepare_request(r)
210211
t0 = datetime.now(UTC)
211212
req_descr = describe_request(prep, t0)
@@ -242,6 +243,7 @@ async def _create_isa(self, isa_id):
242243
url,
243244
json=payload,
244245
)
246+
# TODO: Do not rely on a prepared request that is not actually used in order to create the Query RequestDescription; instead build it from the request actually made
245247
prep = self._dss.client.prepare_request(r)
246248
t0 = datetime.now(UTC)
247249
req_descr = describe_request(prep, t0)
@@ -272,6 +274,7 @@ async def _delete_isa(self, isa_id, isa_version):
272274
"DELETE",
273275
url,
274276
)
277+
# TODO: Do not rely on a prepared request that is not actually used in order to create the Query RequestDescription; instead build it from the request actually made
275278
prep = self._dss.client.prepare_request(r)
276279
t0 = datetime.now(UTC)
277280
req_descr = describe_request(prep, t0)

pyproject.toml

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,22 @@ default-groups = []
6363

6464
[tool.ruff]
6565
target-version = "py313"
66+
extend-exclude = [
67+
"interfaces/*",
68+
"monitoring/prober/output/*",
69+
"monitoring/mock_uss/output/*",
70+
"monitoring/uss_qualifier/output/*",
71+
]
72+
line-length = 88
6673

74+
[tool.ruff.lint]
6775
# Default + isort + pyupgrade
68-
lint.select = [
76+
select = [
6977
"E4", "E7", "E9", "F", "I", "UP"
7078
]
71-
extend-exclude = [
72-
"interfaces/*",
73-
"monitoring/prober/output/*",
74-
"monitoring/mock_uss/output/*",
75-
"monitoring/uss_qualifier/output/*",
76-
]
77-
line-length = 88
79+
# Explicitly ignore UP045 (Optional[Foo] -> Foo | None)
80+
ignore = ["UP045"]
81+
unfixable = ["UP045"]
7882

7983
[tool.basedpyright]
8084
typeCheckingMode = "standard"

schemas/monitoring/monitorlib/fetch/RequestDescription.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
"description": "Path to content that replaces the $ref",
88
"type": "string"
99
},
10+
"auth_dt": {
11+
"description": "Amount of time required to obtain authorization before performing the primary query (de minimus or unknown by default).",
12+
"format": "duration",
13+
"type": [
14+
"string",
15+
"null"
16+
]
17+
},
1018
"body": {
1119
"type": [
1220
"string",

0 commit comments

Comments
 (0)