mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-27 05:01:08 -05:00
* Implement Per-Collection Prometheus Metrics
* Update config/config.yaml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update tests/per_collection_metrics_test.sh
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update tests/per_collection_metrics_test.sh
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix ci
* comment
* Update tests/per_collection_metrics_test.sh
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* adress revew
* fix: linter
* refactor: cardinality limit, anonymize fix, cleanup
- Add max_per_collection_metrics config (default 256) to bound
per-collection label cardinality
- Fix anonymize: strip per_collection_responses entirely instead of
leaking hashed collection names
- Move CollectionName into requests_telemetry, remove
telemetry_context.rs
- Add unit tests for cardinality limits
- Add integration test assertions for default mode
- Regenerate OpenAPI spec
- Document why internal gRPC doesn't attach CollectionName
* avoid cloning
* fix: address pr reviews
* fix: linter
* fix: linter
* feat: enforce {name} in actix api
* chore: remove empty line
* feat: add actix pre-commit hook
* feat: potential enforce tonic collection name
* feat: use proc_macro instead
* feat: use collection_name instead of name
* feat: add tonic telemetry tests
* fix: linter
* fix: linter
* chore: remove unneccessary clone
* fix: use collection_name everywhere
* fix: python tests and openapi
* chore: simplify collection_name tests
* actix collection_name enforcign: use relative path in test + avoid grep
* fix: remove macro
* chore: add some comment to telemetry_wrapper
* fix: clippy
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Boros <dancixx@gmail.com>
Co-authored-by: generall <andrey@vasnetsov.com>
192 lines
6.8 KiB
Python
192 lines
6.8 KiB
Python
import pytest
|
|
from datetime import datetime
|
|
|
|
from .helpers.collection_setup import basic_collection_setup, drop_collection
|
|
from .helpers.helpers import request_with_validation
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="module")
|
|
def setup(on_disk_vectors, collection_name):
|
|
basic_collection_setup(collection_name=collection_name, on_disk_vectors=on_disk_vectors)
|
|
yield
|
|
drop_collection(collection_name=collection_name)
|
|
|
|
|
|
def test_metrics():
|
|
response = request_with_validation(
|
|
api='/metrics',
|
|
method="GET",
|
|
)
|
|
assert response.ok
|
|
|
|
# Probe some strings that must exist in the metrics output
|
|
assert '# HELP app_info information about qdrant server' in response.text
|
|
assert '# TYPE app_info gauge' in response.text
|
|
assert 'app_info{name="qdrant",version="' in response.text
|
|
assert 'collections_total ' in response.text
|
|
|
|
|
|
def test_metrics_default_no_per_collection(collection_name):
|
|
"""By default (per_collection not requested), request metrics must NOT have a collection label."""
|
|
# Make a request that hits a whitelisted endpoint so metrics are populated
|
|
request_with_validation(
|
|
api='/collections/{collection_name}/points/scroll',
|
|
method="POST",
|
|
path_params={'collection_name': collection_name},
|
|
body={"limit": 1},
|
|
)
|
|
|
|
response = request_with_validation(
|
|
api='/metrics',
|
|
method="GET",
|
|
)
|
|
assert response.ok
|
|
|
|
# REST request metrics should exist (global mode)
|
|
assert 'rest_responses_total' in response.text
|
|
|
|
# Existing collection-level metrics should use "id" label, not "collection"
|
|
assert f'collection_points{{id="{collection_name}"}}' in response.text \
|
|
or f'id="{collection_name}"' in response.text
|
|
|
|
# Per-collection request metrics (collection= label on rest_/grpc_ metrics) should NOT appear
|
|
for line in response.text.splitlines():
|
|
if line.startswith('#'):
|
|
continue
|
|
if 'rest_responses_' in line or 'grpc_responses_' in line:
|
|
assert 'collection=' not in line, (
|
|
f"Per-collection label found in default mode: {line}"
|
|
)
|
|
|
|
|
|
def test_metrics_with_per_collection(collection_name):
|
|
"""When per_collection=true is requested, request metrics must have a collection label."""
|
|
# Make a request that hits a whitelisted endpoint so metrics are populated
|
|
request_with_validation(
|
|
api='/collections/{collection_name}/points/scroll',
|
|
method="POST",
|
|
path_params={'collection_name': collection_name},
|
|
body={"limit": 1},
|
|
)
|
|
|
|
response = request_with_validation(
|
|
api='/metrics',
|
|
method="GET",
|
|
query_params={'per_collection': 'true'},
|
|
)
|
|
assert response.ok
|
|
|
|
# Per-collection mode: collection label should appear
|
|
found_per_collection = False
|
|
for line in response.text.splitlines():
|
|
if line.startswith('#'):
|
|
continue
|
|
if 'rest_responses_' in line and f'collection="{collection_name}"' in line:
|
|
found_per_collection = True
|
|
break
|
|
|
|
assert found_per_collection, "Per-collection label not found when per_collection=true"
|
|
|
|
|
|
def test_telemetry():
|
|
response = request_with_validation(
|
|
api='/telemetry',
|
|
method="GET",
|
|
)
|
|
|
|
assert response.ok
|
|
|
|
result = response.json()['result']
|
|
|
|
assert result['collections']['number_of_collections'] >= 1
|
|
|
|
endpoint = result['requests']['rest']['responses']['PUT /collections/{collection_name}/points']
|
|
assert endpoint['200']['count'] > 0
|
|
|
|
assert 'avg_duration_micros' in endpoint['200']
|
|
|
|
# By default, per_collection_responses should be absent (per_collection not requested)
|
|
assert 'per_collection_responses' not in result['requests']['rest'], \
|
|
"per_collection_responses should not appear when per_collection is not requested"
|
|
assert 'per_collection_responses' not in result['requests']['grpc'], \
|
|
"per_collection_responses should not appear when per_collection is not requested"
|
|
|
|
|
|
@pytest.mark.parametrize("level", [0, 1, 2, 3, 10])
|
|
def test_telemetry_detail(level: int):
|
|
response = request_with_validation(
|
|
api='/telemetry',
|
|
method="GET",
|
|
query_params={'details_level': level},
|
|
)
|
|
|
|
assert response.ok
|
|
|
|
result = response.json()['result']
|
|
|
|
assert result['collections']['number_of_collections'] >= 1
|
|
|
|
endpoint = result['requests']['rest']['responses']['PUT /collections/{collection_name}/points']
|
|
assert endpoint['200']['count'] > 0
|
|
|
|
if level == 0:
|
|
assert set(result.keys()) == {'id', 'app', 'collections', 'cluster', 'requests'}
|
|
assert set(result['collections'].keys()) == {'number_of_collections'}
|
|
return
|
|
else:
|
|
assert set(result.keys()) == {'id', 'app', 'collections', 'cluster', 'requests', 'memory', 'hardware'}
|
|
assert set(result['collections'].keys()) == {'number_of_collections', 'collections', 'snapshots'}
|
|
|
|
last_queried = endpoint['200']['last_responded'].replace('Z', '+00:00')
|
|
last_queried = datetime.fromisoformat(last_queried)
|
|
# Assert today
|
|
assert last_queried.date() == datetime.now().date()
|
|
|
|
collection = result['collections']['collections'][0]
|
|
|
|
if level == 1:
|
|
assert set(collection.keys()) == {'vectors', 'optimizers_status', 'params'}
|
|
elif level == 2:
|
|
assert set(collection.keys()) == {'id', 'init_time_ms', 'config'}
|
|
elif level >= 3:
|
|
assert set(collection.keys()) == {'id', 'init_time_ms', 'config', 'shards', 'transfers', 'resharding'}
|
|
|
|
shard = collection['shards'][0]
|
|
assert set(shard.keys()) == {'id', 'key', 'local', 'remote', 'replicate_states', 'partial_snapshot'}
|
|
|
|
local_shard = shard['local']
|
|
|
|
if level == 3:
|
|
assert set(local_shard.keys()) == {
|
|
'variant_name', 'status', 'total_optimized_points', 'vectors_size_bytes',
|
|
'payloads_size_bytes', 'num_points', 'num_vectors', 'num_vectors_by_name', 'optimizations', 'async_scorer',
|
|
'update_queue'
|
|
}
|
|
elif level >= 4:
|
|
assert set(local_shard.keys()) == {
|
|
'variant_name', 'status', 'total_optimized_points', 'vectors_size_bytes',
|
|
'payloads_size_bytes', 'num_points', 'num_vectors', 'num_vectors_by_name', 'segments', 'optimizations',
|
|
'async_scorer', 'indexed_only_excluded_vectors', 'update_queue'
|
|
}
|
|
|
|
segment = local_shard['segments'][0]
|
|
assert set(segment.keys()) == {'info', 'config', 'vector_index_searches', 'payload_field_indices'}
|
|
|
|
|
|
def test_issues():
|
|
response = request_with_validation(
|
|
api='/issues',
|
|
method="GET",
|
|
)
|
|
assert response.ok
|
|
result = response.json()['result']['issues']
|
|
assert len(result) == 0
|
|
|
|
response = request_with_validation(
|
|
api='/issues',
|
|
method="DELETE",
|
|
)
|
|
assert response.ok
|
|
result = response.json()['result']
|
|
assert result
|