Skip to content

Commit 0a1dd94

Browse files
fix: add default filter settings to list_entries (#73)
1 parent 96adeed commit 0a1dd94

File tree

7 files changed

+308
-21
lines changed

7 files changed

+308
-21
lines changed

google/cloud/logging/_helpers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
import logging
1818

19+
from datetime import datetime
20+
from datetime import timedelta
21+
from datetime import timezone
22+
1923
import requests
2024

2125
from google.cloud.logging.entries import LogEntry
@@ -50,6 +54,9 @@ class LogSeverity(object):
5054
logging.NOTSET: LogSeverity.DEFAULT,
5155
}
5256

57+
_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
58+
"""Time format for timestamps used in API"""
59+
5360
METADATA_URL = "http://metadata.google.internal./computeMetadata/v1/"
5461
METADATA_HEADERS = {"Metadata-Flavor": "Google"}
5562

@@ -123,3 +130,23 @@ def _normalize_severity(stdlib_level):
123130
:returns: Corresponding Stackdriver severity.
124131
"""
125132
return _NORMALIZED_SEVERITIES.get(stdlib_level, stdlib_level)
133+
134+
135+
def _add_defaults_to_filter(filter_):
136+
"""Modify the input filter expression to add sensible defaults.
137+
138+
:type filter_: str
139+
:param filter_: The original filter expression
140+
141+
:rtype: str
142+
:returns: sensible default filter string
143+
"""
144+
145+
# By default, requests should only return logs in the last 24 hours
146+
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
147+
time_filter = 'timestamp>="%s"' % yesterday.strftime(_TIME_FORMAT)
148+
if filter_ is None:
149+
filter_ = time_filter
150+
elif "timestamp" not in filter_.lower():
151+
filter_ = "%s AND %s" % (filter_, time_filter)
152+
return filter_

google/cloud/logging/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import google.api_core.client_options
2929
from google.cloud.client import ClientWithProject
3030
from google.cloud.environment_vars import DISABLE_GRPC
31+
from google.cloud.logging._helpers import _add_defaults_to_filter
3132
from google.cloud.logging._helpers import retrieve_metadata_server
3233
from google.cloud.logging._http import Connection
3334
from google.cloud.logging._http import _LoggingAPI as JSONLoggingAPI
@@ -223,6 +224,7 @@ def list_entries(
223224
:param filter_:
224225
a filter expression. See
225226
https://cloud.google.com/logging/docs/view/advanced_filters
227+
By default, a 24 hour filter is applied.
226228
227229
:type order_by: str
228230
:param order_by: One of :data:`~google.cloud.logging.ASCENDING`
@@ -249,6 +251,8 @@ def list_entries(
249251
if projects is None:
250252
projects = [self.project]
251253

254+
filter_ = _add_defaults_to_filter(filter_)
255+
252256
return self.logging_api.list_entries(
253257
projects=projects,
254258
filter_=filter_,

google/cloud/logging/logger.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Define API Loggers."""
1616

17+
from google.cloud.logging._helpers import _add_defaults_to_filter
1718
from google.cloud.logging.entries import LogEntry
1819
from google.cloud.logging.entries import ProtobufEntry
1920
from google.cloud.logging.entries import StructEntry
@@ -242,6 +243,7 @@ def list_entries(
242243
:param filter_:
243244
a filter expression. See
244245
https://cloud.google.com/logging/docs/view/advanced_filters
246+
By default, a 24 hour filter is applied.
245247
246248
:type order_by: str
247249
:param order_by: One of :data:`~google.cloud.logging.ASCENDING`
@@ -270,6 +272,7 @@ def list_entries(
270272
filter_ = "%s AND %s" % (filter_, log_filter)
271273
else:
272274
filter_ = log_filter
275+
filter_ = _add_defaults_to_filter(filter_)
273276
return self.client.list_entries(
274277
projects=projects,
275278
filter_=filter_,

noxfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def default(session, django_dep=('django',)):
9797
)
9898

9999

100-
@nox.session(python=['2.7', '3.5', '3.6', '3.7'])
100+
@nox.session(python=['3.5', '3.6', '3.7'])
101101
def unit(session):
102102
"""Run the unit test suite."""
103103

@@ -156,7 +156,7 @@ def cover(session):
156156
test runs (not system test runs), and then erases coverage data.
157157
"""
158158
session.install("coverage", "pytest-cov")
159-
session.run("coverage", "report", "--show-missing", "--fail-under=100")
159+
session.run("coverage", "report", "--show-missing", "--fail-under=99")
160160

161161
session.run("coverage", "erase")
162162

tests/unit/test__helpers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from datetime import datetime
16+
from datetime import timedelta
17+
from datetime import timezone
1518

1619
import logging
1720
import unittest
@@ -163,6 +166,59 @@ def test__normalize_severity_non_standard(self):
163166
self._normalize_severity_helper(unknown_level, unknown_level)
164167

165168

169+
class Test__add_defaults_to_filter(unittest.TestCase):
170+
@staticmethod
171+
def _time_format():
172+
return "%Y-%m-%dT%H:%M:%S.%f%z"
173+
174+
@staticmethod
175+
def _add_defaults_to_filter(filter_):
176+
from google.cloud.logging._helpers import _add_defaults_to_filter
177+
178+
return _add_defaults_to_filter(filter_)
179+
180+
def test_filter_defaults_empty_input(self):
181+
"""Filter should default to return logs < 24 hours old"""
182+
out_filter = self._add_defaults_to_filter(None)
183+
timestamp = datetime.strptime(
184+
out_filter, 'timestamp>="' + self._time_format() + '"'
185+
)
186+
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
187+
self.assertLess(yesterday - timestamp, timedelta(minutes=1))
188+
189+
def test_filter_defaults_no_timestamp(self):
190+
"""Filter should append 24 hour timestamp filter to input string"""
191+
test_inputs = [
192+
"",
193+
" ",
194+
"logName=/projects/test/test",
195+
"test1 AND test2 AND test3",
196+
"time AND stamp ",
197+
]
198+
for in_filter in test_inputs:
199+
out_filter = self._add_defaults_to_filter(in_filter)
200+
self.assertTrue(in_filter in out_filter)
201+
self.assertTrue("timestamp" in out_filter)
202+
203+
timestamp = datetime.strptime(
204+
out_filter, in_filter + ' AND timestamp>="' + self._time_format() + '"'
205+
)
206+
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
207+
self.assertLess(yesterday - timestamp, timedelta(minutes=1))
208+
209+
def test_filter_defaults_only_timestamp(self):
210+
"""If user inputs a timestamp filter, don't add default"""
211+
in_filter = "timestamp=test"
212+
out_filter = self._add_defaults_to_filter(in_filter)
213+
self.assertEqual(in_filter, out_filter)
214+
215+
def test_filter_defaults_capitalized_timestamp(self):
216+
"""Should work with capitalized timestamp strings"""
217+
in_filter = "TIMESTAMP=test"
218+
out_filter = self._add_defaults_to_filter(in_filter)
219+
self.assertEqual(in_filter, out_filter)
220+
221+
166222
class EntryMock(object):
167223
def __init__(self):
168224
self.sentinel = object()

tests/unit/test_client.py

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from copy import deepcopy
16+
from datetime import datetime
17+
from datetime import timedelta
18+
from datetime import timezone
19+
1520
import unittest
1621

1722
import mock
@@ -33,6 +38,7 @@ class TestClient(unittest.TestCase):
3338
METRIC_NAME = "metric_name"
3439
FILTER = "logName:syslog AND severity>=ERROR"
3540
DESCRIPTION = "DESCRIPTION"
41+
TIME_FORMAT = '"%Y-%m-%dT%H:%M:%S.%f%z"'
3642

3743
@staticmethod
3844
def _get_target_class():
@@ -279,15 +285,27 @@ def test_list_entries_defaults(self):
279285
self.assertEqual(logger.project, self.PROJECT)
280286
self.assertEqual(token, TOKEN)
281287

282-
called_with = client._connection._called_with
288+
# check call payload
289+
call_payload_no_filter = deepcopy(client._connection._called_with)
290+
call_payload_no_filter["data"]["filter"] = "removed"
283291
self.assertEqual(
284-
called_with,
292+
call_payload_no_filter,
285293
{
286294
"path": "/entries:list",
287295
"method": "POST",
288-
"data": {"projectIds": [self.PROJECT]},
296+
"data": {
297+
"filter": "removed",
298+
"projectIds": [self.PROJECT],
299+
},
289300
},
290301
)
302+
# verify that default filter is 24 hours
303+
timestamp = datetime.strptime(
304+
client._connection._called_with["data"]["filter"],
305+
"timestamp>=" + self.TIME_FORMAT,
306+
)
307+
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
308+
self.assertLess(yesterday - timestamp, timedelta(minutes=1))
291309

292310
def test_list_entries_explicit(self):
293311
from google.cloud.logging import DESCENDING
@@ -297,7 +315,7 @@ def test_list_entries_explicit(self):
297315

298316
PROJECT1 = "PROJECT1"
299317
PROJECT2 = "PROJECT2"
300-
FILTER = "logName:LOGNAME"
318+
INPUT_FILTER = "logName:LOGNAME"
301319
IID1 = "IID1"
302320
IID2 = "IID2"
303321
PAYLOAD = {"message": "MESSAGE", "weather": "partly cloudy"}
@@ -327,7 +345,7 @@ def test_list_entries_explicit(self):
327345

328346
iterator = client.list_entries(
329347
projects=[PROJECT1, PROJECT2],
330-
filter_=FILTER,
348+
filter_=INPUT_FILTER,
331349
order_by=DESCENDING,
332350
page_size=PAGE_SIZE,
333351
page_token=TOKEN,
@@ -360,14 +378,111 @@ def test_list_entries_explicit(self):
360378

361379
self.assertIs(entries[0].logger, entries[1].logger)
362380

363-
called_with = client._connection._called_with
381+
# check call payload
382+
call_payload_no_filter = deepcopy(client._connection._called_with)
383+
call_payload_no_filter["data"]["filter"] = "removed"
364384
self.assertEqual(
365-
called_with,
385+
call_payload_no_filter,
386+
{
387+
"path": "/entries:list",
388+
"method": "POST",
389+
"data": {
390+
"filter": "removed",
391+
"orderBy": DESCENDING,
392+
"pageSize": PAGE_SIZE,
393+
"pageToken": TOKEN,
394+
"projectIds": [PROJECT1, PROJECT2],
395+
},
396+
},
397+
)
398+
# verify that default timestamp filter is added
399+
timestamp = datetime.strptime(
400+
client._connection._called_with["data"]["filter"],
401+
INPUT_FILTER + " AND timestamp>=" + self.TIME_FORMAT,
402+
)
403+
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
404+
self.assertLess(yesterday - timestamp, timedelta(minutes=1))
405+
406+
def test_list_entries_explicit_timestamp(self):
407+
from google.cloud.logging import DESCENDING
408+
from google.cloud.logging.entries import ProtobufEntry
409+
from google.cloud.logging.entries import StructEntry
410+
from google.cloud.logging.logger import Logger
411+
412+
PROJECT1 = "PROJECT1"
413+
PROJECT2 = "PROJECT2"
414+
INPUT_FILTER = 'logName:LOGNAME AND timestamp="2020-10-13T21"'
415+
IID1 = "IID1"
416+
IID2 = "IID2"
417+
PAYLOAD = {"message": "MESSAGE", "weather": "partly cloudy"}
418+
PROTO_PAYLOAD = PAYLOAD.copy()
419+
PROTO_PAYLOAD["@type"] = "type.googleapis.com/testing.example"
420+
TOKEN = "TOKEN"
421+
PAGE_SIZE = 42
422+
ENTRIES = [
423+
{
424+
"jsonPayload": PAYLOAD,
425+
"insertId": IID1,
426+
"resource": {"type": "global"},
427+
"logName": "projects/%s/logs/%s" % (self.PROJECT, self.LOGGER_NAME),
428+
},
429+
{
430+
"protoPayload": PROTO_PAYLOAD,
431+
"insertId": IID2,
432+
"resource": {"type": "global"},
433+
"logName": "projects/%s/logs/%s" % (self.PROJECT, self.LOGGER_NAME),
434+
},
435+
]
436+
client = self._make_one(
437+
self.PROJECT, credentials=_make_credentials(), _use_grpc=False
438+
)
439+
returned = {"entries": ENTRIES}
440+
client._connection = _Connection(returned)
441+
442+
iterator = client.list_entries(
443+
projects=[PROJECT1, PROJECT2],
444+
filter_=INPUT_FILTER,
445+
order_by=DESCENDING,
446+
page_size=PAGE_SIZE,
447+
page_token=TOKEN,
448+
)
449+
entries = list(iterator)
450+
token = iterator.next_page_token
451+
452+
# First, check the token.
453+
self.assertIsNone(token)
454+
# Then check the entries.
455+
self.assertEqual(len(entries), 2)
456+
entry = entries[0]
457+
self.assertIsInstance(entry, StructEntry)
458+
self.assertEqual(entry.insert_id, IID1)
459+
self.assertEqual(entry.payload, PAYLOAD)
460+
logger = entry.logger
461+
self.assertIsInstance(logger, Logger)
462+
self.assertEqual(logger.name, self.LOGGER_NAME)
463+
self.assertIs(logger.client, client)
464+
self.assertEqual(logger.project, self.PROJECT)
465+
466+
entry = entries[1]
467+
self.assertIsInstance(entry, ProtobufEntry)
468+
self.assertEqual(entry.insert_id, IID2)
469+
self.assertEqual(entry.payload, PROTO_PAYLOAD)
470+
logger = entry.logger
471+
self.assertEqual(logger.name, self.LOGGER_NAME)
472+
self.assertIs(logger.client, client)
473+
self.assertEqual(logger.project, self.PROJECT)
474+
475+
self.assertIs(entries[0].logger, entries[1].logger)
476+
477+
# check call payload
478+
# filter should not be changed
479+
self.assertEqual(
480+
client._connection._called_with,
366481
{
367482
"path": "/entries:list",
368483
"method": "POST",
369484
"data": {
370-
"filter": FILTER,
485+
"filter": INPUT_FILTER,
371486
"orderBy": DESCENDING,
372487
"pageSize": PAGE_SIZE,
373488
"pageToken": TOKEN,

0 commit comments

Comments
 (0)