Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit 6caba34

Browse files
committed
added system tests
1 parent 28d162b commit 6caba34

1 file changed

Lines changed: 273 additions & 15 deletions

File tree

tests/system/data/test_metrics.py

Lines changed: 273 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from functools import partial
2323

2424
from google.api_core.exceptions import NotFound, ServiceUnavailable, DeadlineExceeded
25+
from google.cloud.bigtable.data._metrics.data_model import OperationType
2526
from google.cloud.bigtable.data import MutationsExceptionGroup
2627
from google.api_core import retry
2728

@@ -36,6 +37,16 @@
3637
# if _populate_calls is modified, this value will have to change
3738
EXPECTED_METRIC_COUNT = 172
3839

40+
ALL_INSTRUMENTS = [
41+
"operation_latencies",
42+
"attempt_latencies",
43+
"server_latencies",
44+
"first_response_latencies",
45+
"application_blocking_latencies",
46+
"client_blocking_latencies",
47+
"retry_count",
48+
"connectivity_error_count"
49+
]
3950

4051
async def _populate_calls(client, instance_id, table_id):
4152
"""
@@ -118,14 +129,16 @@ def side_effect(*args, **kwargs):
118129
await stub(retryable_errors=(ServiceUnavailable,))
119130

120131
# Calls hit retryable errors, then hit deadline
132+
# should have 2 attempts, through mocked backoff
121133
print("populating retryable timeout rpcs...")
122134
async with table_with_profile("retry_then_timeout") as table:
123135
stubs = {k:v for k,v in _get_stubs_for_table(table).items() if k not in non_retryable_rpcs}
124136
for rpc_name, stub_list in stubs.items():
125-
with mock.patch(f"google.cloud.bigtable_v2.BigtableAsyncClient.{rpc_name}", side_effect=ServiceUnavailable("test")):
126-
for stub in stub_list:
127-
with pytest.raises((DeadlineExceeded, MutationsExceptionGroup)):
128-
await stub(operation_timeout=0.5, retryable_errors=(ServiceUnavailable,))
137+
with mock.patch("google.cloud.bigtable.data._helpers.exp_sleep_generator", side_effect=[0, 0, 5]):
138+
with mock.patch(f"google.cloud.bigtable_v2.BigtableAsyncClient.{rpc_name}", side_effect=ServiceUnavailable("test")):
139+
for stub in stub_list:
140+
with pytest.raises((DeadlineExceeded, MutationsExceptionGroup)):
141+
await stub(operation_timeout=0.5, retryable_errors=(ServiceUnavailable,))
129142

130143
# Calls hit retryable errors, then hit terminal exception
131144
print("populating retryable then terminal error rpcs...")
@@ -156,18 +169,8 @@ def _read_metrics():
156169
client = MetricServiceClient()
157170
end_time = Timestamp()
158171
end_time.GetCurrentTime()
159-
all_instruments = [
160-
"operation_latencies",
161-
"attempt_latencies",
162-
"server_latencies",
163-
"first_response_latencies",
164-
"application_blocking_latencies",
165-
"client_blocking_latencies",
166-
"retry_count",
167-
"connectivity_error_count"
168-
]
169172

170-
for instrument in all_instruments:
173+
for instrument in ALL_INSTRUMENTS:
171174
response = client.list_time_series(
172175
name=f"projects/{project_id}",
173176
filter=f'metric.type="bigtable.googleapis.com/client/{instrument}" AND resource.labels.instance="{instance_id}" AND resource.labels.table="{table_id}"',
@@ -213,3 +216,258 @@ async def test_resource(get_all_metrics, instance_id, table_id, project_id):
213216
assert resource.labels["zone"] == "global"
214217
assert resource.labels["cluster"] == "unspecified"
215218

219+
@pytest.mark.asyncio
220+
async def test_client_name(get_all_metrics):
221+
"""
222+
all metrics should have client_name populated consistently
223+
"""
224+
from google.cloud.bigtable import __version__
225+
for m in get_all_metrics:
226+
client_name = m.metric.labels["client_name"]
227+
assert client_name == "python-bigtable/" + __version__
228+
229+
@pytest.mark.asyncio
230+
async def test_app_profile(get_all_metrics):
231+
"""
232+
all metrics should have app_profile populated with one of the test values
233+
"""
234+
supported_app_profiles = ["success", "terminal_exception", "retry_then_success", "retry_then_timeout", "retry_then_terminal"]
235+
for m in get_all_metrics:
236+
app_profile = m.metric.labels["app_profile"]
237+
assert app_profile in supported_app_profiles
238+
239+
@pytest.mark.asyncio
240+
async def test_latency_data_types(get_all_metrics):
241+
"""
242+
all latency metrics should have metric_kind DELTA and value_type DISTRIBUTION
243+
"""
244+
latency_metrics = [m for m in get_all_metrics if "latencies" in m.metric.type]
245+
# ensure we got all metrics
246+
assert len(latency_metrics) > 100
247+
assert any("operation_latencies" in m.metric.type for m in latency_metrics)
248+
assert any("attempt_latencies" in m.metric.type for m in latency_metrics)
249+
assert any("server_latencies" in m.metric.type for m in latency_metrics)
250+
assert any("first_response_latencies" in m.metric.type for m in latency_metrics)
251+
assert any("application_blocking_latencies" in m.metric.type for m in latency_metrics)
252+
assert any("client_blocking_latencies" in m.metric.type for m in latency_metrics)
253+
# ensure all types are correct
254+
for m in latency_metrics:
255+
assert m.metric_kind == 2 # DELTA
256+
assert m.value_type == 5 # DISTRIBUTION
257+
258+
@pytest.mark.asyncio
259+
async def test_count_data_types(get_all_metrics):
260+
"""
261+
all count metrics should have metric_kind DELTA and value_type INT64
262+
"""
263+
count_metrics = [m for m in get_all_metrics if "count" in m.metric.type]
264+
# ensure we got all metrics
265+
assert len(count_metrics) > 25
266+
assert any("retry_count" in m.metric.type for m in count_metrics)
267+
assert any("connectivity_error_count" in m.metric.type for m in count_metrics)
268+
# ensure all types are correct
269+
for m in count_metrics:
270+
assert m.metric_kind == 2 # DELTA
271+
assert m.value_type == 2 # INT64
272+
273+
@pytest.mark.asyncio
274+
@pytest.mark.parametrize("instrument,methods", [
275+
("operation_latencies", list(OperationType)), # all operation types
276+
("attempt_latencies", list(OperationType)),
277+
("server_latencies", list(OperationType)),
278+
("application_blocking_latencies", list(OperationType)),
279+
("client_blocking_latencies", list(OperationType)),
280+
("first_response_latencies", [OperationType.READ_ROWS]), # only valid for ReadRows
281+
("connectivity_error_count", list(OperationType)),
282+
("retry_count", [OperationType.READ_ROWS, OperationType.SAMPLE_ROW_KEYS, OperationType.BULK_MUTATE_ROWS, OperationType.MUTATE_ROW]), # only valid for retryable operations
283+
])
284+
async def test_full_method_coverage(get_all_metrics, instrument, methods):
285+
"""
286+
ensure that each instrument type has data for all expected rpc methods
287+
"""
288+
filtered_metrics = [m for m in get_all_metrics if instrument in m.metric.type]
289+
assert len(filtered_metrics) > 0
290+
# ensure all methods are covered
291+
for method in methods:
292+
assert any(method.value in m.metric.labels["method"] for m in filtered_metrics), f"{method} not found in {instrument}"
293+
# ensure no unexpected methods are covered
294+
for m in filtered_metrics:
295+
assert m.metric.labels["method"] in [m.value for m in methods], f"unexpected method {m.metric.labels['method']}"
296+
297+
@pytest.mark.asyncio
298+
@pytest.mark.parametrize("instrument,include_status,include_streaming", [
299+
("operation_latencies", True, True),
300+
("attempt_latencies", True, True),
301+
("server_latencies", True, True),
302+
("first_response_latencies", True, False),
303+
("connectivity_error_count", True, False),
304+
("retry_count", True, False),
305+
("application_blocking_latencies", False, False),
306+
("client_blocking_latencies", False, False),
307+
])
308+
async def test_labels(get_all_metrics, instrument, include_status, include_streaming):
309+
"""
310+
all metrics have 3 common labels: method, client_name, app_profile
311+
312+
some metrics also have status and streaming labels
313+
"""
314+
assert len(get_all_metrics) > 0
315+
filtered_metrics = [m for m in get_all_metrics if instrument in m.metric.type]
316+
expected_num = 3 + int(include_status) + int(include_streaming)
317+
for m in filtered_metrics:
318+
labels = m.metric.labels
319+
# check for count
320+
assert len(labels) == expected_num
321+
# check for common labels
322+
assert "client_name" in labels
323+
assert "method" in labels
324+
assert "app_profile" in labels
325+
# check for optional labels
326+
if include_status:
327+
assert "status" in labels
328+
if include_streaming:
329+
assert "streaming" in labels
330+
331+
@pytest.mark.asyncio
332+
async def test_streaming_label(get_all_metrics):
333+
"""
334+
streaming=True indicates point-reads using ReadRows rpc
335+
336+
We should only set it set on ReadRows, and we should see a mix of True and False in
337+
the dataset
338+
"""
339+
# find set of metrics that support streaming tag
340+
streaming_instruments = ["operation_latencies", "attempt_latencies", "server_latencies"]
341+
streaming_metrics = [m for m in get_all_metrics if any(i in m.metric.type for i in streaming_instruments)]
342+
non_read_rows = [m for m in streaming_metrics if m.metric.labels["method"] != OperationType.READ_ROWS.value]
343+
assert len(non_read_rows) > 50
344+
# ensure all non-read-rows have streaming=False
345+
assert all(m.metric.labels["streaming"] == "false" for m in non_read_rows)
346+
# ensure read-rows have a mix of True and False, for each instrument
347+
for instrument in streaming_instruments:
348+
filtered_read_rows = [m for m in streaming_metrics if instrument in m.metric.type and m.metric.labels["method"] == OperationType.READ_ROWS.value]
349+
assert len(filtered_read_rows) > 0
350+
assert any(m.metric.labels["streaming"] == "true" for m in filtered_read_rows)
351+
assert any(m.metric.labels["streaming"] == "false" for m in filtered_read_rows)
352+
353+
@pytest.mark.asyncio
354+
async def test_status_success(get_all_metrics):
355+
"""
356+
check the subset of successful rpcs
357+
358+
They should have no retries, no connectivity errors, a status of OK
359+
Should have cluster and zone properly set
360+
"""
361+
success_metrics = [m for m in get_all_metrics if m.metric.labels["app_profile"] == "success"]
362+
# ensure each expected instrument is present in data
363+
assert any("operation_latencies" in m.metric.type for m in success_metrics)
364+
assert any("attempt_latencies" in m.metric.type for m in success_metrics)
365+
assert any("server_latencies" in m.metric.type for m in success_metrics)
366+
assert any("first_response_latencies" in m.metric.type for m in success_metrics)
367+
assert any("application_blocking_latencies" in m.metric.type for m in success_metrics)
368+
assert any("client_blocking_latencies" in m.metric.type for m in success_metrics)
369+
for m in success_metrics:
370+
# ensure no retries or connectivity errors recorded
371+
assert "connectivity_error_count" not in m.metric.type
372+
assert "retry_count" not in m.metric.type
373+
# if instrument has status label, should be OK
374+
if "status" in m.metric.labels:
375+
assert m.metric.labels["status"] == "OK"
376+
# check for cluster and zone
377+
assert m.resource.labels["zone"] == TEST_ZONE
378+
assert m.resource.labels["cluster"] == TEST_CLUSTER
379+
380+
@pytest.mark.asyncio
381+
async def test_status_exception(get_all_metrics):
382+
"""
383+
check the subset of rpcs with a single terminal exception
384+
385+
They should have no retries, 1 connectivity errors, a status of UNAVAILABLE
386+
Should have default values for cluster and zone
387+
"""
388+
fail_metrics = [m for m in get_all_metrics if m.metric.labels["app_profile"] == "terminal_exception"]
389+
# ensure each expected instrument is present in data
390+
assert any("operation_latencies" in m.metric.type for m in fail_metrics)
391+
assert any("attempt_latencies" in m.metric.type for m in fail_metrics)
392+
assert any("server_latencies" in m.metric.type for m in fail_metrics)
393+
assert any("first_response_latencies" in m.metric.type for m in fail_metrics)
394+
assert any("application_blocking_latencies" in m.metric.type for m in fail_metrics)
395+
assert any("client_blocking_latencies" in m.metric.type for m in fail_metrics)
396+
assert any("connectivity_error_count" in m.metric.type for m in fail_metrics)
397+
for m in fail_metrics:
398+
# ensure no retries or connectivity errors recorded
399+
assert "retry_count" not in m.metric.type
400+
# if instrument has status label, should be UNAVAILABLE
401+
if "status" in m.metric.labels:
402+
assert m.metric.labels["status"] == "UNAVAILABLE"
403+
# check for cluster and zone
404+
assert m.resource.labels["zone"] == 'global'
405+
assert m.resource.labels["cluster"] == 'unspecified'
406+
# ensure connectivity error count is 1
407+
connectivity_error_counts = [m for m in fail_metrics if "connectivity_error_count" in m.metric.type]
408+
assert len(connectivity_error_counts) > 0
409+
for m in connectivity_error_counts:
410+
for pt in m.points:
411+
assert pt.value.int64_value == 1
412+
413+
@pytest.mark.asyncio
414+
@pytest.mark.parametrize("app_profile,final_status", [
415+
("retryn_then_success", "OK"),
416+
("retry_then_terminal", "NOT_FOUND"),
417+
("retry_then_timeout", "DEADLINE_EXCEEDED"),
418+
])
419+
async def test_status_retry(get_all_metrics, app_profile, final_status):
420+
"""
421+
check the subset of calls that fail and retry
422+
423+
All retry metrics should have 2 attempts before reaching final status
424+
425+
Should have retries, connectivity_errors, and status of `final_status`.
426+
cluster and zone may or may not change from the default value, depending on the final status
427+
"""
428+
# get set of all retry_then_success metrics
429+
retry_metrics = [m for m in get_all_metrics if m.metric.labels["app_profile"] == app_profile]
430+
# find each relevant instrument
431+
retry_counts = [m for m in retry_metrics if "retry_count" in m.metric.type]
432+
assert len(retry_counts) > 0
433+
connectivity_error_counts = [m for m in retry_metrics if "connectivity_error_count" in m.metric.type]
434+
assert len(connectivity_error_counts) > 0
435+
operation_latencies = [m for m in retry_metrics if "operation_latencies" in m.metric.type]
436+
assert len(operation_latencies) > 0
437+
attempt_latencies = [m for m in retry_metrics if "attempt_latencies" in m.metric.type]
438+
assert len(attempt_latencies) > 0
439+
server_latencies = [m for m in retry_metrics if "server_latencies" in m.metric.type] # may not be present. only if reached server
440+
first_response_latencies = [m for m in retry_metrics if "first_response_latencies" in m.metric.type] # may not be present
441+
442+
# each retry_count and connectivity_error_count should be 2
443+
for m in retry_counts + connectivity_error_counts:
444+
for pt in m.points:
445+
assert pt.value.int64_value == 2
446+
447+
# all operation-level status should be final_status
448+
for m in operation_latencies + retry_counts:
449+
assert m.metric.labels["status"] == final_status
450+
# all attempt-level status should have a 2:1 mix of final_status and UNAVAILABLE
451+
status_map = {}
452+
for m in attempt_latencies + server_latencies + first_response_latencies + connectivity_error_counts:
453+
status_map[m.metric.labels["status"]] = status_map.get(m.metric.labels["status"], 0) + 1
454+
assert len(status_map) == 2
455+
assert final_status in status_map
456+
assert "UNAVAILABLE" in status_map
457+
assert len(status_map[final_status]) * 2 == len(status_map["UNAVAILABLE"])
458+
459+
@pytest.mark.asyncio
460+
async def test_latency_metric_histogram_buckets(get_all_metrics):
461+
"""
462+
latency metrics should all have histogram buckets set up properly
463+
"""
464+
from google.cloud.bigtable.data._metrics.handlers.gcp_exporter import MILLIS_AGGREGATION
465+
filtered = [m for m in get_all_metrics if "latency" in m.metric.type]
466+
all_values = [pt.value.distribution_value for m in filtered for pt in m.points]
467+
for v in all_values:
468+
# check bucket schema
469+
assert v.bucket_options.explicit_buckets.bounds == MILLIS_AGGREGATION.boundaries
470+
# check for reasobble values
471+
assert v.count > 0
472+
assert v.mean > 0
473+
assert v.mean < 5000

0 commit comments

Comments
 (0)