Skip to content

Commit ad70add

Browse files
committed
feat(qdrant): Start Qdrant database integration
1 parent 5080c76 commit ad70add

File tree

5 files changed

+830
-0
lines changed

5 files changed

+830
-0
lines changed

sentry_sdk/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,8 @@ class OP:
418418
COHERE_CHAT_COMPLETIONS_CREATE = "ai.chat_completions.create.cohere"
419419
COHERE_EMBEDDINGS_CREATE = "ai.embeddings.create.cohere"
420420
DB = "db"
421+
DB_QDRANT_GRPC = "db.qdrant.grpc"
422+
DB_QDRANT_REST = "db.qdrant.rest"
421423
DB_REDIS = "db.redis"
422424
EVENT_DJANGO = "event.django"
423425
FUNCTION = "function"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from sentry_sdk.integrations import DidNotEnable
2+
3+
try:
4+
from qdrant_client.http import ApiClient, AsyncApiClient
5+
import grpc
6+
except ImportError:
7+
raise DidNotEnable("Qdrant client not installed")
8+
9+
from sentry_sdk.integrations import Integration
10+
from sentry_sdk.integrations.qdrant.consts import _IDENTIFIER
11+
from sentry_sdk.integrations.qdrant.qdrant import (
12+
_sync_api_client_send_inner,
13+
_async_api_client_send_inner,
14+
_wrap_channel_sync,
15+
_wrap_channel_async,
16+
)
17+
18+
19+
class QdrantIntegration(Integration):
20+
identifier = _IDENTIFIER
21+
22+
def __init__(self, mute_children_http_spans=True):
23+
# type: (bool) -> None
24+
self.mute_children_http_spans = mute_children_http_spans
25+
26+
@staticmethod
27+
def setup_once():
28+
# type: () -> None
29+
30+
# hooks for the REST client
31+
ApiClient.send_inner = _sync_api_client_send_inner(ApiClient.send_inner)
32+
AsyncApiClient.send_inner = _async_api_client_send_inner(
33+
AsyncApiClient.send_inner
34+
)
35+
36+
# hooks for the gRPC client
37+
grpc.secure_channel = _wrap_channel_sync(grpc.secure_channel)
38+
grpc.insecure_channel = _wrap_channel_sync(grpc.insecure_channel)
39+
grpc.aio.secure_channel = _wrap_channel_async(grpc.aio.secure_channel)
40+
grpc.aio.insecure_channel = _wrap_channel_async(grpc.aio.insecure_channel)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from sentry_sdk.integrations.qdrant.path_matching import PathTrie
2+
3+
SPAN_ORIGIN = "auto.db.qdrant"
4+
5+
# created from https://github.com/qdrant/qdrant/blob/master/docs/redoc/v1.11.x/openapi.json
6+
# only used for qdrants REST API. gRPC is using other identifiers
7+
_PATH_TO_OPERATION_ID = {
8+
"/collections/{collection_name}/shards": {"put": "create_shard_key"},
9+
"/collections/{collection_name}/shards/delete": {"post": "delete_shard_key"},
10+
"/": {"get": "root"},
11+
"/telemetry": {"get": "telemetry"},
12+
"/metrics": {"get": "metrics"},
13+
"/locks": {"post": "post_locks", "get": "get_locks"},
14+
"/healthz": {"get": "healthz"},
15+
"/livez": {"get": "livez"},
16+
"/readyz": {"get": "readyz"},
17+
"/issues": {"get": "get_issues", "delete": "clear_issues"},
18+
"/cluster": {"get": "cluster_status"},
19+
"/cluster/recover": {"post": "recover_current_peer"},
20+
"/cluster/peer/{peer_id}": {"delete": "remove_peer"},
21+
"/collections": {"get": "get_collections"},
22+
"/collections/{collection_name}": {
23+
"get": "get_collection",
24+
"put": "create_collection",
25+
"patch": "update_collection",
26+
"delete": "delete_collection",
27+
},
28+
"/collections/aliases": {"post": "update_aliases"},
29+
"/collections/{collection_name}/index": {"put": "create_field_index"},
30+
"/collections/{collection_name}/exists": {"get": "collection_exists"},
31+
"/collections/{collection_name}/index/{field_name}": {
32+
"delete": "delete_field_index"
33+
},
34+
"/collections/{collection_name}/cluster": {
35+
"get": "collection_cluster_info",
36+
"post": "update_collection_cluster",
37+
},
38+
"/collections/{collection_name}/aliases": {"get": "get_collection_aliases"},
39+
"/aliases": {"get": "get_collections_aliases"},
40+
"/collections/{collection_name}/snapshots/upload": {
41+
"post": "recover_from_uploaded_snapshot"
42+
},
43+
"/collections/{collection_name}/snapshots/recover": {
44+
"put": "recover_from_snapshot"
45+
},
46+
"/collections/{collection_name}/snapshots": {
47+
"get": "list_snapshots",
48+
"post": "create_snapshot",
49+
},
50+
"/collections/{collection_name}/snapshots/{snapshot_name}": {
51+
"delete": "delete_snapshot",
52+
"get": "get_snapshot",
53+
},
54+
"/snapshots": {"get": "list_full_snapshots", "post": "create_full_snapshot"},
55+
"/snapshots/{snapshot_name}": {
56+
"delete": "delete_full_snapshot",
57+
"get": "get_full_snapshot",
58+
},
59+
"/collections/{collection_name}/shards/{shard_id}/snapshots/upload": {
60+
"post": "recover_shard_from_uploaded_snapshot"
61+
},
62+
"/collections/{collection_name}/shards/{shard_id}/snapshots/recover": {
63+
"put": "recover_shard_from_snapshot"
64+
},
65+
"/collections/{collection_name}/shards/{shard_id}/snapshots": {
66+
"get": "list_shard_snapshots",
67+
"post": "create_shard_snapshot",
68+
},
69+
"/collections/{collection_name}/shards/{shard_id}/snapshots/{snapshot_name}": {
70+
"delete": "delete_shard_snapshot",
71+
"get": "get_shard_snapshot",
72+
},
73+
"/collections/{collection_name}/points/{id}": {"get": "get_point"},
74+
"/collections/{collection_name}/points": {
75+
"post": "get_points",
76+
"put": "upsert_points",
77+
},
78+
"/collections/{collection_name}/points/delete": {"post": "delete_points"},
79+
"/collections/{collection_name}/points/vectors": {"put": "update_vectors"},
80+
"/collections/{collection_name}/points/vectors/delete": {"post": "delete_vectors"},
81+
"/collections/{collection_name}/points/payload": {
82+
"post": "set_payload",
83+
"put": "overwrite_payload",
84+
},
85+
"/collections/{collection_name}/points/payload/delete": {"post": "delete_payload"},
86+
"/collections/{collection_name}/points/payload/clear": {"post": "clear_payload"},
87+
"/collections/{collection_name}/points/batch": {"post": "batch_update"},
88+
"/collections/{collection_name}/points/scroll": {"post": "scroll_points"},
89+
"/collections/{collection_name}/points/search": {"post": "search_points"},
90+
"/collections/{collection_name}/points/search/batch": {
91+
"post": "search_batch_points"
92+
},
93+
"/collections/{collection_name}/points/search/groups": {
94+
"post": "search_point_groups"
95+
},
96+
"/collections/{collection_name}/points/recommend": {"post": "recommend_points"},
97+
"/collections/{collection_name}/points/recommend/batch": {
98+
"post": "recommend_batch_points"
99+
},
100+
"/collections/{collection_name}/points/recommend/groups": {
101+
"post": "recommend_point_groups"
102+
},
103+
"/collections/{collection_name}/points/discover": {"post": "discover_points"},
104+
"/collections/{collection_name}/points/discover/batch": {
105+
"post": "discover_batch_points"
106+
},
107+
"/collections/{collection_name}/points/count": {"post": "count_points"},
108+
"/collections/{collection_name}/points/query": {"post": "query_points"},
109+
"/collections/{collection_name}/points/query/batch": {"post": "query_batch_points"},
110+
"/collections/{collection_name}/points/query/groups": {
111+
"post": "query_points_groups"
112+
},
113+
}
114+
115+
_DISALLOWED_PROTO_FIELDS = {"data", "keyword"}
116+
117+
_DISALLOWED_REST_FIELDS = {"nearest", "value"}
118+
119+
_IDENTIFIER = "qdrant"
120+
121+
_qdrant_trie = PathTrie(_PATH_TO_OPERATION_ID)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from typing import Any, Dict, Optional, List
2+
3+
4+
class TrieNode:
5+
def __init__(self, is_placeholder=False):
6+
"""
7+
Initializes a TrieNode.
8+
9+
:param is_placeholder: Indicates if this node represents a placeholder (wildcard).
10+
"""
11+
self.children = {} # type: Dict[str, 'TrieNode']
12+
self.operation_ids = {} # type: Dict[str, str]
13+
self.is_placeholder = is_placeholder # type: bool
14+
15+
@classmethod
16+
def from_dict(cls, data, parent_path=""):
17+
# type: (Dict[str, Any], str) -> 'TrieNode'
18+
"""
19+
Recursively constructs a TrieNode from a nested dictionary.
20+
21+
:param data: Nested dictionary mapping path segments to either nested dictionaries
22+
or dictionaries of HTTP methods to operation IDs.
23+
:param parent_path: The accumulated path from the root to the current node.
24+
:return: Root TrieNode of the constructed trie.
25+
"""
26+
node = cls()
27+
for path, methods in data.items():
28+
segments = PathTrie.split_path(path)
29+
current = node
30+
for segment in segments:
31+
is_placeholder = segment.startswith("{") and segment.endswith("}")
32+
key = "*" if is_placeholder else segment
33+
34+
if key not in current.children:
35+
current.children[key] = TrieNode(is_placeholder=is_placeholder)
36+
current = current.children[key]
37+
38+
if isinstance(methods, dict):
39+
for method, operation_id in methods.items():
40+
current.operation_ids[method.lower()] = operation_id
41+
42+
return node
43+
44+
def to_dict(self, current_path=""):
45+
# type: (str) -> Dict[str, Any]
46+
"""
47+
Serializes the TrieNode and its children back to a nested dictionary.
48+
49+
:param current_path: The accumulated path from the root to the current node.
50+
:return: Nested dictionary representing the trie.
51+
"""
52+
result = {} # type: Dict[str, Any]
53+
if self.operation_ids:
54+
path_key = current_path or "/"
55+
result[path_key] = self.operation_ids.copy()
56+
57+
for segment, child in self.children.items():
58+
# replace wildcard '*' back to placeholder format if necessary.
59+
# allows for TrieNode.from_dict(TrieNode.to_dict()) to be idempotent.
60+
display_segment = "{placeholder}" if child.is_placeholder else segment
61+
new_path = (
62+
f"{current_path}/{display_segment}"
63+
if current_path
64+
else f"/{display_segment}"
65+
)
66+
child_dict = child.to_dict(new_path)
67+
result.update(child_dict)
68+
69+
return result
70+
71+
72+
class PathTrie:
73+
WILDCARD = "*" # type: str
74+
75+
def __init__(self, data=None):
76+
# type: (Optional[Dict[str, Any]]) -> None
77+
"""
78+
Initializes the PathTrie with optional initial data.
79+
80+
:param data: Optional nested dictionary to initialize the trie.
81+
"""
82+
self.root = TrieNode.from_dict(data or {}) # type: TrieNode
83+
84+
def insert(self, path, method, operation_id):
85+
# type: (str, str, str) -> None
86+
"""
87+
Inserts a path into the trie with its corresponding HTTP method and operation ID.
88+
89+
:param path: The API path (e.g., '/users/{user_id}/posts').
90+
:param method: HTTP method (e.g., 'GET', 'POST').
91+
:param operation_id: The operation identifier associated with the path and method.
92+
"""
93+
current = self.root
94+
segments = self.split_path(path)
95+
96+
for segment in segments:
97+
is_placeholder = self._is_placeholder(segment)
98+
key = self.WILDCARD if is_placeholder else segment
99+
100+
if key not in current.children:
101+
current.children[key] = TrieNode(is_placeholder=is_placeholder)
102+
current = current.children[key]
103+
104+
current.operation_ids[method.lower()] = operation_id
105+
106+
def match(self, path, method):
107+
# type: (str, str) -> Optional[str]
108+
"""
109+
Matches a given path and HTTP method to its corresponding operation ID.
110+
111+
:param path: The API path to match.
112+
:param method: HTTP method to match.
113+
:return: The operation ID if a match is found; otherwise, None.
114+
"""
115+
current = self.root
116+
segments = self.split_path(path)
117+
118+
for segment in segments:
119+
if segment in current.children:
120+
current = current.children[segment]
121+
elif self.WILDCARD in current.children:
122+
current = current.children[self.WILDCARD]
123+
else:
124+
return None
125+
126+
return current.operation_ids.get(method.lower())
127+
128+
def to_dict(self):
129+
# type: () -> Dict[str, Any]
130+
return self.root.to_dict()
131+
132+
@staticmethod
133+
def split_path(path):
134+
# type: (str) -> List[str]
135+
return [segment for segment in path.strip("/").split("/") if segment]
136+
137+
@staticmethod
138+
def _is_placeholder(segment):
139+
# type: (str) -> bool
140+
return segment.startswith("{") and segment.endswith("}")
141+
142+
def __repr__(self):
143+
# type: () -> str
144+
return f"PathTrie({self.to_dict()})"

0 commit comments

Comments
 (0)