|
22 | 22 | from functools import partial |
23 | 23 |
|
24 | 24 | from google.api_core.exceptions import NotFound, ServiceUnavailable, DeadlineExceeded |
| 25 | +from google.cloud.bigtable.data._metrics.data_model import OperationType |
25 | 26 | from google.cloud.bigtable.data import MutationsExceptionGroup |
26 | 27 | from google.api_core import retry |
27 | 28 |
|
|
36 | 37 | # if _populate_calls is modified, this value will have to change |
37 | 38 | EXPECTED_METRIC_COUNT = 172 |
38 | 39 |
|
| 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 | +] |
39 | 50 |
|
40 | 51 | async def _populate_calls(client, instance_id, table_id): |
41 | 52 | """ |
@@ -118,14 +129,16 @@ def side_effect(*args, **kwargs): |
118 | 129 | await stub(retryable_errors=(ServiceUnavailable,)) |
119 | 130 |
|
120 | 131 | # Calls hit retryable errors, then hit deadline |
| 132 | + # should have 2 attempts, through mocked backoff |
121 | 133 | print("populating retryable timeout rpcs...") |
122 | 134 | async with table_with_profile("retry_then_timeout") as table: |
123 | 135 | stubs = {k:v for k,v in _get_stubs_for_table(table).items() if k not in non_retryable_rpcs} |
124 | 136 | 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,)) |
129 | 142 |
|
130 | 143 | # Calls hit retryable errors, then hit terminal exception |
131 | 144 | print("populating retryable then terminal error rpcs...") |
@@ -156,18 +169,8 @@ def _read_metrics(): |
156 | 169 | client = MetricServiceClient() |
157 | 170 | end_time = Timestamp() |
158 | 171 | 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 | | - ] |
169 | 172 |
|
170 | | - for instrument in all_instruments: |
| 173 | + for instrument in ALL_INSTRUMENTS: |
171 | 174 | response = client.list_time_series( |
172 | 175 | name=f"projects/{project_id}", |
173 | 176 | 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): |
213 | 216 | assert resource.labels["zone"] == "global" |
214 | 217 | assert resource.labels["cluster"] == "unspecified" |
215 | 218 |
|
| 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