offline-sync-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- offline_sync_sdk-0.2.0.dist-info/METADATA +447 -0
- offline_sync_sdk-0.2.0.dist-info/RECORD +22 -0
- offline_sync_sdk-0.2.0.dist-info/WHEEL +4 -0
- offline_sync_sdk-0.2.0.dist-info/entry_points.txt +3 -0
- sync_sdk/__init__.py +39 -0
- sync_sdk/adapters/__init__.py +1 -0
- sync_sdk/adapters/direct.py +15 -0
- sync_sdk/adapters/http.py +71 -0
- sync_sdk/adapters/mongodb.py +473 -0
- sync_sdk/adapters/opentelemetry.py +231 -0
- sync_sdk/adapters/sqlalchemy.py +691 -0
- sync_sdk/adapters/sqlalchemy_config.py +145 -0
- sync_sdk/client.py +198 -0
- sync_sdk/gateway.py +163 -0
- sync_sdk/integrations/__init__.py +1 -0
- sync_sdk/integrations/fastapi.py +32 -0
- sync_sdk/models.py +41 -0
- sync_sdk/ports.py +94 -0
- sync_sdk/py.typed +1 -0
- sync_sdk/registry.py +44 -0
- sync_sdk/store.py +147 -0
- sync_sdk/telemetry.py +131 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""MongoDB adapter for application collections and SDK sync metadata.
|
|
2
|
+
|
|
3
|
+
The adapter uses PyMongo's native asyncio API and multi-document transactions.
|
|
4
|
+
MongoDB must run as a replica set or sharded cluster. Registered payload fields
|
|
5
|
+
are written to application-owned collections; SDK state lives in prefixed
|
|
6
|
+
metadata collections in the same database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
18
|
+
from pymongo import ReturnDocument
|
|
19
|
+
from pymongo.asynchronous.client_session import AsyncClientSession
|
|
20
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
21
|
+
from pymongo.errors import CollectionInvalid, ConnectionFailure, PyMongoError
|
|
22
|
+
from pymongo.read_concern import ReadConcern
|
|
23
|
+
from pymongo.write_concern import WriteConcern
|
|
24
|
+
|
|
25
|
+
from ..gateway import SyncGateway
|
|
26
|
+
from ..models import Change, ChangeBatch, Mutation, MutationResult
|
|
27
|
+
from ..ports import MutationRejected, RetryableError
|
|
28
|
+
from ..registry import ConflictPolicy, Entity, EntityRegistry, optimistic_version
|
|
29
|
+
from ..telemetry import PipelineTelemetry, safe_telemetry
|
|
30
|
+
|
|
31
|
+
Document = dict[str, Any]
|
|
32
|
+
_METADATA_SUFFIXES = ("clock", "versions", "processed_mutations", "change_log")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _collection_names(prefix: str) -> dict[str, str]:
|
|
36
|
+
if not prefix or "\0" in prefix or prefix.startswith("system."):
|
|
37
|
+
raise ValueError("MongoDB metadata prefix is invalid")
|
|
38
|
+
return {suffix: f"{prefix}{suffix}" for suffix in _METADATA_SUFFIXES}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _ensure_collection(database: AsyncDatabase, name: str) -> None:
|
|
42
|
+
if name in await database.list_collection_names():
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
await database.create_collection(name)
|
|
46
|
+
except CollectionInvalid:
|
|
47
|
+
# Another initializer may have created it after the list operation.
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def initialize_mongodb_metadata(
|
|
52
|
+
database: AsyncDatabase, *, metadata_prefix: str = "sync_"
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Create SDK collections and the clock document before serving traffic."""
|
|
55
|
+
names = _collection_names(metadata_prefix)
|
|
56
|
+
for name in names.values():
|
|
57
|
+
await _ensure_collection(database, name)
|
|
58
|
+
await database[names["clock"]].update_one(
|
|
59
|
+
{"_id": "clock"},
|
|
60
|
+
{"$setOnInsert": {"sequence": 0, "lock_epoch": 0}},
|
|
61
|
+
upsert=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MongoDBBinding(Protocol):
|
|
66
|
+
"""Custom MongoDB entity mapping; all operations must use the session."""
|
|
67
|
+
|
|
68
|
+
collection_name: str
|
|
69
|
+
|
|
70
|
+
async def write(
|
|
71
|
+
self,
|
|
72
|
+
database: AsyncDatabase,
|
|
73
|
+
session: AsyncClientSession,
|
|
74
|
+
mutation: Mutation,
|
|
75
|
+
version: int,
|
|
76
|
+
) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MongoCollectionBinding:
|
|
80
|
+
"""Map selected payload fields to top-level MongoDB document fields."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
collection_name: str,
|
|
85
|
+
*,
|
|
86
|
+
fields: Mapping[str, str],
|
|
87
|
+
parse_key: Callable[[str], Any] = str,
|
|
88
|
+
value_decoders: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
89
|
+
):
|
|
90
|
+
if (
|
|
91
|
+
not collection_name
|
|
92
|
+
or "\0" in collection_name
|
|
93
|
+
or collection_name.startswith("system.")
|
|
94
|
+
):
|
|
95
|
+
raise ValueError("MongoDB collection name is invalid")
|
|
96
|
+
if not fields:
|
|
97
|
+
raise ValueError("At least one MongoDB payload field is required")
|
|
98
|
+
if len(set(fields.values())) != len(fields):
|
|
99
|
+
raise ValueError("Payload fields must map to distinct document fields")
|
|
100
|
+
if any(
|
|
101
|
+
not name or name == "_id" or name.startswith("$") or "." in name
|
|
102
|
+
for name in fields.values()
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"Mapped MongoDB fields must be top-level fields other than _id"
|
|
106
|
+
)
|
|
107
|
+
overrides = dict(value_decoders or {})
|
|
108
|
+
if not set(overrides).issubset(fields):
|
|
109
|
+
raise ValueError("value_decoders must refer to registered payload fields")
|
|
110
|
+
self.collection_name = collection_name
|
|
111
|
+
self.fields = dict(fields)
|
|
112
|
+
self.parse_key = parse_key
|
|
113
|
+
self.value_decoders = overrides
|
|
114
|
+
|
|
115
|
+
def encode_key(self, value: Any) -> str:
|
|
116
|
+
encoded = str(value)
|
|
117
|
+
self._decode_key(encoded)
|
|
118
|
+
return encoded
|
|
119
|
+
|
|
120
|
+
def _decode_key(self, entity_id: str) -> Any:
|
|
121
|
+
try:
|
|
122
|
+
value = self.parse_key(entity_id)
|
|
123
|
+
if str(value) != entity_id:
|
|
124
|
+
raise ValueError("Noncanonical key")
|
|
125
|
+
return value
|
|
126
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
127
|
+
raise MutationRejected(
|
|
128
|
+
"Invalid entity ID; use the binding's encode_key()"
|
|
129
|
+
) from exc
|
|
130
|
+
|
|
131
|
+
async def write(
|
|
132
|
+
self,
|
|
133
|
+
database: AsyncDatabase,
|
|
134
|
+
session: AsyncClientSession,
|
|
135
|
+
mutation: Mutation,
|
|
136
|
+
version: int,
|
|
137
|
+
) -> None:
|
|
138
|
+
del version
|
|
139
|
+
key = self._decode_key(mutation.entity_id)
|
|
140
|
+
collection = database[self.collection_name]
|
|
141
|
+
if mutation.operation == "delete":
|
|
142
|
+
await collection.delete_one({"_id": key}, session=session)
|
|
143
|
+
return
|
|
144
|
+
if set(mutation.payload) != set(self.fields):
|
|
145
|
+
raise MutationRejected(
|
|
146
|
+
"Payload must contain exactly the registered MongoDB fields"
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
values = {
|
|
150
|
+
self.fields[field]: self.value_decoders.get(field, lambda item: item)(
|
|
151
|
+
value
|
|
152
|
+
)
|
|
153
|
+
for field, value in mutation.payload.items()
|
|
154
|
+
}
|
|
155
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
156
|
+
raise MutationRejected(
|
|
157
|
+
"Payload value is incompatible with the registered MongoDB field"
|
|
158
|
+
) from exc
|
|
159
|
+
await collection.update_one(
|
|
160
|
+
{"_id": key}, {"$set": values}, upsert=True, session=session
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MongoDBStore:
|
|
165
|
+
"""Transactional MongoDB implementation of the gateway storage contract."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
database: AsyncDatabase,
|
|
170
|
+
bindings: Mapping[str, MongoDBBinding],
|
|
171
|
+
*,
|
|
172
|
+
metadata_prefix: str = "sync_",
|
|
173
|
+
telemetry: PipelineTelemetry | None = None,
|
|
174
|
+
):
|
|
175
|
+
self.database = database
|
|
176
|
+
self.bindings = dict(bindings)
|
|
177
|
+
self.names = _collection_names(metadata_prefix)
|
|
178
|
+
self.telemetry = safe_telemetry(telemetry)
|
|
179
|
+
self._writer_lock = asyncio.Lock()
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _attributes(operation: str, outcome: str) -> dict[str, str]:
|
|
183
|
+
return {
|
|
184
|
+
"db.system.name": "mongodb",
|
|
185
|
+
"sync.storage.operation": operation,
|
|
186
|
+
"sync.outcome": outcome,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@asynccontextmanager
|
|
190
|
+
async def transaction(self):
|
|
191
|
+
started = time.perf_counter()
|
|
192
|
+
outcome = "ok"
|
|
193
|
+
with self.telemetry.span(
|
|
194
|
+
"sync.storage.transaction", {"db.system.name": "mongodb"}
|
|
195
|
+
) as span:
|
|
196
|
+
try:
|
|
197
|
+
async with (
|
|
198
|
+
self._writer_lock,
|
|
199
|
+
self.database.client.start_session() as session,
|
|
200
|
+
):
|
|
201
|
+
transaction = await session.start_transaction(
|
|
202
|
+
read_concern=ReadConcern("snapshot"),
|
|
203
|
+
write_concern=WriteConcern("majority"),
|
|
204
|
+
)
|
|
205
|
+
async with transaction:
|
|
206
|
+
clock = await self.database[
|
|
207
|
+
self.names["clock"]
|
|
208
|
+
].find_one_and_update(
|
|
209
|
+
{"_id": "clock"},
|
|
210
|
+
{"$inc": {"lock_epoch": 1}},
|
|
211
|
+
return_document=ReturnDocument.AFTER,
|
|
212
|
+
session=session,
|
|
213
|
+
)
|
|
214
|
+
if clock is None:
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
"MongoDB sync metadata is not initialized"
|
|
217
|
+
)
|
|
218
|
+
yield _MongoDBTransaction(
|
|
219
|
+
self.database,
|
|
220
|
+
self.bindings,
|
|
221
|
+
self.names,
|
|
222
|
+
session,
|
|
223
|
+
int(clock["sequence"]),
|
|
224
|
+
)
|
|
225
|
+
except (ConnectionFailure, PyMongoError) as exc:
|
|
226
|
+
outcome = "error"
|
|
227
|
+
self.telemetry.count(
|
|
228
|
+
"sync.storage.failures",
|
|
229
|
+
attributes=self._attributes("transaction", outcome),
|
|
230
|
+
)
|
|
231
|
+
if (
|
|
232
|
+
isinstance(exc, ConnectionFailure)
|
|
233
|
+
or exc.has_error_label("TransientTransactionError")
|
|
234
|
+
or exc.has_error_label("UnknownTransactionCommitResult")
|
|
235
|
+
):
|
|
236
|
+
raise RetryableError(
|
|
237
|
+
str(exc), reason="storage", retry_after=0.1
|
|
238
|
+
) from exc
|
|
239
|
+
raise
|
|
240
|
+
except BaseException:
|
|
241
|
+
outcome = "error"
|
|
242
|
+
self.telemetry.count(
|
|
243
|
+
"sync.storage.failures",
|
|
244
|
+
attributes=self._attributes("transaction", outcome),
|
|
245
|
+
)
|
|
246
|
+
raise
|
|
247
|
+
finally:
|
|
248
|
+
attributes = self._attributes("transaction", outcome)
|
|
249
|
+
self.telemetry.count("sync.storage.transactions", attributes=attributes)
|
|
250
|
+
self.telemetry.record(
|
|
251
|
+
"sync.storage.transaction.duration",
|
|
252
|
+
(time.perf_counter() - started) * 1000,
|
|
253
|
+
attributes,
|
|
254
|
+
)
|
|
255
|
+
span.set_attribute("sync.outcome", outcome)
|
|
256
|
+
|
|
257
|
+
async def get_changes(self, cursor: int, limit: int) -> ChangeBatch:
|
|
258
|
+
started = time.perf_counter()
|
|
259
|
+
outcome = "ok"
|
|
260
|
+
try:
|
|
261
|
+
documents = await (
|
|
262
|
+
self.database[self.names["change_log"]]
|
|
263
|
+
.find({"_id": {"$gt": cursor}})
|
|
264
|
+
.sort("_id", 1)
|
|
265
|
+
.limit(limit + 1)
|
|
266
|
+
.to_list(length=limit + 1)
|
|
267
|
+
)
|
|
268
|
+
page = [Change.model_validate(document) for document in documents[:limit]]
|
|
269
|
+
return ChangeBatch(
|
|
270
|
+
cursor=page[-1].sequence if page else cursor,
|
|
271
|
+
has_more=len(documents) > limit,
|
|
272
|
+
changes=page,
|
|
273
|
+
)
|
|
274
|
+
except BaseException:
|
|
275
|
+
outcome = "error"
|
|
276
|
+
self.telemetry.count(
|
|
277
|
+
"sync.storage.failures",
|
|
278
|
+
attributes=self._attributes("pull", outcome),
|
|
279
|
+
)
|
|
280
|
+
raise
|
|
281
|
+
finally:
|
|
282
|
+
self.telemetry.record(
|
|
283
|
+
"sync.storage.pull.duration",
|
|
284
|
+
(time.perf_counter() - started) * 1000,
|
|
285
|
+
self._attributes("pull", outcome),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def health_check(self) -> bool:
|
|
289
|
+
started = time.perf_counter()
|
|
290
|
+
outcome = "ok"
|
|
291
|
+
try:
|
|
292
|
+
response = await self.database.command("ping")
|
|
293
|
+
clock = await self.database[self.names["clock"]].find_one(
|
|
294
|
+
{"_id": "clock"}, projection={"_id": 1}
|
|
295
|
+
)
|
|
296
|
+
healthy = response.get("ok") == 1 and clock is not None
|
|
297
|
+
if not healthy:
|
|
298
|
+
outcome = "error"
|
|
299
|
+
return healthy
|
|
300
|
+
except (PyMongoError, OSError):
|
|
301
|
+
outcome = "error"
|
|
302
|
+
return False
|
|
303
|
+
finally:
|
|
304
|
+
self.telemetry.record(
|
|
305
|
+
"sync.storage.health.duration",
|
|
306
|
+
(time.perf_counter() - started) * 1000,
|
|
307
|
+
self._attributes("health", outcome),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class _MongoDBTransaction:
|
|
312
|
+
def __init__(
|
|
313
|
+
self,
|
|
314
|
+
database: AsyncDatabase,
|
|
315
|
+
bindings: Mapping[str, MongoDBBinding],
|
|
316
|
+
names: Mapping[str, str],
|
|
317
|
+
session: AsyncClientSession,
|
|
318
|
+
sequence: int,
|
|
319
|
+
):
|
|
320
|
+
self.database = database
|
|
321
|
+
self.bindings = bindings
|
|
322
|
+
self.names = names
|
|
323
|
+
self.session = session
|
|
324
|
+
self.sequence = sequence
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _row_id(entity: str, entity_id: str) -> Document:
|
|
328
|
+
return {"entity": entity, "entity_id": entity_id}
|
|
329
|
+
|
|
330
|
+
async def get_result(self, mutation_id: UUID) -> MutationResult | None:
|
|
331
|
+
document = await self.database[self.names["processed_mutations"]].find_one(
|
|
332
|
+
{"_id": str(mutation_id)}, session=self.session
|
|
333
|
+
)
|
|
334
|
+
return (
|
|
335
|
+
MutationResult.model_validate(document["result"])
|
|
336
|
+
if document is not None
|
|
337
|
+
else None
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def get_version(self, entity: str, entity_id: str) -> int:
|
|
341
|
+
document = await self.database[self.names["versions"]].find_one(
|
|
342
|
+
{"_id": self._row_id(entity, entity_id)}, session=self.session
|
|
343
|
+
)
|
|
344
|
+
return int(document["version"]) if document is not None else 0
|
|
345
|
+
|
|
346
|
+
async def write(self, mutation: Mutation, version: int) -> None:
|
|
347
|
+
binding = self.bindings.get(mutation.entity)
|
|
348
|
+
if binding is None:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"No MongoDB binding for registered entity: {mutation.entity}"
|
|
351
|
+
)
|
|
352
|
+
await binding.write(self.database, self.session, mutation, version)
|
|
353
|
+
await self.database[self.names["versions"]].replace_one(
|
|
354
|
+
{"_id": self._row_id(mutation.entity, mutation.entity_id)},
|
|
355
|
+
{
|
|
356
|
+
"_id": self._row_id(mutation.entity, mutation.entity_id),
|
|
357
|
+
"version": version,
|
|
358
|
+
},
|
|
359
|
+
upsert=True,
|
|
360
|
+
session=self.session,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def append_change(self, mutation: Mutation, version: int) -> None:
|
|
364
|
+
self.sequence += 1
|
|
365
|
+
await self.database[self.names["change_log"]].insert_one(
|
|
366
|
+
{
|
|
367
|
+
"_id": self.sequence,
|
|
368
|
+
"sequence": self.sequence,
|
|
369
|
+
"entity": mutation.entity,
|
|
370
|
+
"entity_id": mutation.entity_id,
|
|
371
|
+
"operation": mutation.operation,
|
|
372
|
+
"payload": mutation.payload,
|
|
373
|
+
"version": version,
|
|
374
|
+
"created_at": datetime.now(UTC),
|
|
375
|
+
},
|
|
376
|
+
session=self.session,
|
|
377
|
+
)
|
|
378
|
+
await self.database[self.names["clock"]].update_one(
|
|
379
|
+
{"_id": "clock"},
|
|
380
|
+
{"$set": {"sequence": self.sequence}},
|
|
381
|
+
session=self.session,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def save_result(self, mutation: Mutation, result: MutationResult) -> None:
|
|
385
|
+
await self.database[self.names["processed_mutations"]].insert_one(
|
|
386
|
+
{
|
|
387
|
+
"_id": str(mutation.mutation_id),
|
|
388
|
+
"client_id": mutation.client_id,
|
|
389
|
+
"result": result.model_dump(mode="json"),
|
|
390
|
+
"processed_at": datetime.now(UTC),
|
|
391
|
+
},
|
|
392
|
+
session=self.session,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class MongoDBConfig:
|
|
397
|
+
"""Register MongoDB collections without performing database I/O."""
|
|
398
|
+
|
|
399
|
+
def __init__(self, *, metadata_prefix: str = "sync_"):
|
|
400
|
+
self.metadata_prefix = metadata_prefix
|
|
401
|
+
self._metadata_names = set(_collection_names(metadata_prefix).values())
|
|
402
|
+
self._bindings: dict[str, MongoCollectionBinding] = {}
|
|
403
|
+
self._entities: dict[str, Entity] = {}
|
|
404
|
+
|
|
405
|
+
def register_collection(
|
|
406
|
+
self,
|
|
407
|
+
name: str,
|
|
408
|
+
collection_name: str,
|
|
409
|
+
*,
|
|
410
|
+
fields: Mapping[str, str] | None = None,
|
|
411
|
+
schema: type[BaseModel] | None = None,
|
|
412
|
+
parse_key: Callable[[str], Any] = str,
|
|
413
|
+
value_decoders: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
414
|
+
conflict_policy: ConflictPolicy = optimistic_version,
|
|
415
|
+
) -> MongoCollectionBinding:
|
|
416
|
+
if not name or len(name) > 191 or name in self._bindings:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"Entity names must be unique and contain 1 to 191 characters"
|
|
419
|
+
)
|
|
420
|
+
if collection_name in self._metadata_names:
|
|
421
|
+
raise ValueError("Sync metadata collections cannot store business data")
|
|
422
|
+
if any(
|
|
423
|
+
binding.collection_name == collection_name
|
|
424
|
+
for binding in self._bindings.values()
|
|
425
|
+
):
|
|
426
|
+
raise ValueError(
|
|
427
|
+
"Register a MongoDB collection once to preserve one version history"
|
|
428
|
+
)
|
|
429
|
+
if fields is None:
|
|
430
|
+
if schema is None:
|
|
431
|
+
raise ValueError("Provide fields or a schema for a MongoDB collection")
|
|
432
|
+
fields = {field: field for field in schema.model_fields}
|
|
433
|
+
binding = MongoCollectionBinding(
|
|
434
|
+
collection_name,
|
|
435
|
+
fields=fields,
|
|
436
|
+
parse_key=parse_key,
|
|
437
|
+
value_decoders=value_decoders,
|
|
438
|
+
)
|
|
439
|
+
if schema is None:
|
|
440
|
+
definitions: dict[str, Any] = {field: (Any, ...) for field in fields}
|
|
441
|
+
schema = create_model(
|
|
442
|
+
f"{name}Payload",
|
|
443
|
+
__config__=ConfigDict(extra="forbid"),
|
|
444
|
+
**definitions,
|
|
445
|
+
)
|
|
446
|
+
self._bindings[name] = binding
|
|
447
|
+
self._entities[name] = Entity(name, schema, conflict_policy)
|
|
448
|
+
return binding
|
|
449
|
+
|
|
450
|
+
def build(
|
|
451
|
+
self,
|
|
452
|
+
database: AsyncDatabase,
|
|
453
|
+
*,
|
|
454
|
+
telemetry: PipelineTelemetry | None = None,
|
|
455
|
+
) -> SyncGateway:
|
|
456
|
+
return SyncGateway(
|
|
457
|
+
MongoDBStore(
|
|
458
|
+
database,
|
|
459
|
+
self._bindings,
|
|
460
|
+
metadata_prefix=self.metadata_prefix,
|
|
461
|
+
telemetry=telemetry,
|
|
462
|
+
),
|
|
463
|
+
EntityRegistry(self._entities.values()),
|
|
464
|
+
telemetry=telemetry,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
async def create_collections(self, database: AsyncDatabase) -> None:
|
|
468
|
+
"""Create registered collections, SDK collections, and the clock."""
|
|
469
|
+
for binding in self._bindings.values():
|
|
470
|
+
await _ensure_collection(database, binding.collection_name)
|
|
471
|
+
await initialize_mongodb_metadata(
|
|
472
|
+
database, metadata_prefix=self.metadata_prefix
|
|
473
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Send pipeline traces and metrics to OpenTelemetry and SigNoz."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import AbstractContextManager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opentelemetry import metrics, trace
|
|
9
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
10
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
11
|
+
from opentelemetry.metrics import Observation
|
|
12
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
13
|
+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
14
|
+
from opentelemetry.sdk.resources import Resource
|
|
15
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
16
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
17
|
+
|
|
18
|
+
from ..telemetry import Attributes, TelemetrySpan
|
|
19
|
+
|
|
20
|
+
_DESCRIPTIONS = {
|
|
21
|
+
"sync.client.queue.depth": "Changes waiting in the local queue",
|
|
22
|
+
"sync.client.mutations": "Mutation results received by the client",
|
|
23
|
+
"sync.client.changes.pulled": "Changes received by the client",
|
|
24
|
+
"sync.client.transport.failures": "Temporary send or receive failures",
|
|
25
|
+
"sync.client.connection.failures": "Connection failures while syncing",
|
|
26
|
+
"sync.client.retries": "Retries scheduled by the client",
|
|
27
|
+
"sync.client.failures": "Client sync failures that were not retried",
|
|
28
|
+
"sync.gateway.mutations": "Mutations handled by the gateway",
|
|
29
|
+
"sync.gateway.conflicts": "Mutation conflicts",
|
|
30
|
+
"sync.gateway.failed_mutations": "Rejected mutations",
|
|
31
|
+
"sync.gateway.batch.failures": "Gateway batches that failed",
|
|
32
|
+
"sync.gateway.pull.failures": "Gateway change reads that failed",
|
|
33
|
+
"sync.gateway.changes.returned": "Changes returned by the gateway",
|
|
34
|
+
"sync.client.retry.delay": "Time before the next retry",
|
|
35
|
+
"sync.client.sync.duration": "Time used by one client sync run",
|
|
36
|
+
"sync.gateway.batch.duration": "Time used to handle a mutation batch",
|
|
37
|
+
"sync.gateway.pull.duration": "Time used to read server changes",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_UNITS = {
|
|
41
|
+
"sync.client.queue.depth": "{mutation}",
|
|
42
|
+
"sync.client.mutations": "{mutation}",
|
|
43
|
+
"sync.client.changes.pulled": "{change}",
|
|
44
|
+
"sync.client.transport.failures": "{failure}",
|
|
45
|
+
"sync.client.connection.failures": "{failure}",
|
|
46
|
+
"sync.client.retries": "{retry}",
|
|
47
|
+
"sync.client.failures": "{failure}",
|
|
48
|
+
"sync.gateway.mutations": "{mutation}",
|
|
49
|
+
"sync.gateway.conflicts": "{conflict}",
|
|
50
|
+
"sync.gateway.failed_mutations": "{mutation}",
|
|
51
|
+
"sync.gateway.batch.failures": "{failure}",
|
|
52
|
+
"sync.gateway.pull.failures": "{failure}",
|
|
53
|
+
"sync.gateway.changes.returned": "{change}",
|
|
54
|
+
"sync.client.retry.delay": "s",
|
|
55
|
+
"sync.client.sync.duration": "ms",
|
|
56
|
+
"sync.gateway.batch.duration": "ms",
|
|
57
|
+
"sync.gateway.pull.duration": "ms",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpenTelemetryTelemetry:
|
|
62
|
+
"""Write the pipeline's spans and metrics to OpenTelemetry."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
tracer_provider: TracerProvider | None = None,
|
|
68
|
+
meter_provider: MeterProvider | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.tracer = trace.get_tracer("sync_sdk", tracer_provider=tracer_provider)
|
|
71
|
+
self.meter = metrics.get_meter("sync_sdk", meter_provider=meter_provider)
|
|
72
|
+
self._counters: dict[str, Any] = {}
|
|
73
|
+
self._histograms: dict[str, Any] = {}
|
|
74
|
+
self._gauges: dict[str, Any] = {}
|
|
75
|
+
self._gauge_values: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
76
|
+
|
|
77
|
+
def span(
|
|
78
|
+
self, name: str, attributes: Attributes | None = None
|
|
79
|
+
) -> AbstractContextManager[TelemetrySpan]:
|
|
80
|
+
return self.tracer.start_as_current_span(
|
|
81
|
+
name, attributes=dict(attributes or {})
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def count(
|
|
85
|
+
self, name: str, value: int = 1, attributes: Attributes | None = None
|
|
86
|
+
) -> None:
|
|
87
|
+
instrument = self._counters.get(name)
|
|
88
|
+
if instrument is None:
|
|
89
|
+
instrument = self.meter.create_counter(
|
|
90
|
+
name,
|
|
91
|
+
unit=_UNITS.get(name, "1"),
|
|
92
|
+
description=_DESCRIPTIONS.get(name, ""),
|
|
93
|
+
)
|
|
94
|
+
self._counters[name] = instrument
|
|
95
|
+
instrument.add(value, dict(attributes or {}))
|
|
96
|
+
|
|
97
|
+
def record(
|
|
98
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
99
|
+
) -> None:
|
|
100
|
+
instrument = self._histograms.get(name)
|
|
101
|
+
if instrument is None:
|
|
102
|
+
instrument = self.meter.create_histogram(
|
|
103
|
+
name,
|
|
104
|
+
unit=_UNITS.get(name, "1"),
|
|
105
|
+
description=_DESCRIPTIONS.get(name, ""),
|
|
106
|
+
)
|
|
107
|
+
self._histograms[name] = instrument
|
|
108
|
+
instrument.record(value, dict(attributes or {}))
|
|
109
|
+
|
|
110
|
+
def gauge(
|
|
111
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
112
|
+
) -> None:
|
|
113
|
+
self._gauge_values[name] = (value, dict(attributes or {}))
|
|
114
|
+
if name in self._gauges:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
def observe(_options: Any) -> list[Observation]:
|
|
118
|
+
current, current_attributes = self._gauge_values[name]
|
|
119
|
+
return [Observation(current, current_attributes)]
|
|
120
|
+
|
|
121
|
+
self._gauges[name] = self.meter.create_observable_gauge(
|
|
122
|
+
name,
|
|
123
|
+
callbacks=[observe],
|
|
124
|
+
unit=_UNITS.get(name, "1"),
|
|
125
|
+
description=_DESCRIPTIONS.get(name, ""),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class OpenTelemetryRuntime:
|
|
131
|
+
"""OpenTelemetry objects owned by one app process."""
|
|
132
|
+
|
|
133
|
+
telemetry: OpenTelemetryTelemetry
|
|
134
|
+
tracer_provider: TracerProvider
|
|
135
|
+
meter_provider: MeterProvider
|
|
136
|
+
|
|
137
|
+
def force_flush(self, timeout_millis: int = 10_000) -> bool:
|
|
138
|
+
traces = self.tracer_provider.force_flush(timeout_millis)
|
|
139
|
+
metrics_ok = self.meter_provider.force_flush(timeout_millis)
|
|
140
|
+
return bool(traces and metrics_ok)
|
|
141
|
+
|
|
142
|
+
def shutdown(self) -> None:
|
|
143
|
+
self.tracer_provider.shutdown()
|
|
144
|
+
self.meter_provider.shutdown()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def setup_signoz(
|
|
148
|
+
service_name: str,
|
|
149
|
+
*,
|
|
150
|
+
endpoint: str | None = None,
|
|
151
|
+
headers: Mapping[str, str] | None = None,
|
|
152
|
+
service_version: str | None = None,
|
|
153
|
+
environment: str | None = None,
|
|
154
|
+
fastapi_app: Any | None = None,
|
|
155
|
+
instrument_httpx: bool = False,
|
|
156
|
+
export_interval_millis: int = 60_000,
|
|
157
|
+
) -> OpenTelemetryRuntime:
|
|
158
|
+
"""Set up OTLP export for SigNoz. Call once when a process starts.
|
|
159
|
+
|
|
160
|
+
If endpoint and headers are left out, the standard OTEL environment variables
|
|
161
|
+
are used. Give a base HTTP endpoint, without /v1/traces or /v1/metrics.
|
|
162
|
+
"""
|
|
163
|
+
if not service_name.strip():
|
|
164
|
+
raise ValueError("service_name cannot be empty")
|
|
165
|
+
if export_interval_millis <= 0:
|
|
166
|
+
raise ValueError("export_interval_millis must be positive")
|
|
167
|
+
if endpoint is not None and endpoint.rstrip("/").endswith(
|
|
168
|
+
("/v1/traces", "/v1/metrics")
|
|
169
|
+
):
|
|
170
|
+
raise ValueError("endpoint must be the base OTLP HTTP address")
|
|
171
|
+
|
|
172
|
+
resource_values = {"service.name": service_name}
|
|
173
|
+
if service_version:
|
|
174
|
+
resource_values["service.version"] = service_version
|
|
175
|
+
if environment:
|
|
176
|
+
resource_values["deployment.environment.name"] = environment
|
|
177
|
+
resource = Resource.create(resource_values)
|
|
178
|
+
|
|
179
|
+
exporter_options: dict[str, Any] = {}
|
|
180
|
+
if headers is not None:
|
|
181
|
+
exporter_options["headers"] = dict(headers)
|
|
182
|
+
|
|
183
|
+
trace_options = dict(exporter_options)
|
|
184
|
+
metric_options = dict(exporter_options)
|
|
185
|
+
if endpoint is not None:
|
|
186
|
+
base = endpoint.rstrip("/")
|
|
187
|
+
trace_options["endpoint"] = f"{base}/v1/traces"
|
|
188
|
+
metric_options["endpoint"] = f"{base}/v1/metrics"
|
|
189
|
+
|
|
190
|
+
tracer_provider = TracerProvider(resource=resource)
|
|
191
|
+
tracer_provider.add_span_processor(
|
|
192
|
+
BatchSpanProcessor(OTLPSpanExporter(**trace_options))
|
|
193
|
+
)
|
|
194
|
+
meter_provider = MeterProvider(
|
|
195
|
+
resource=resource,
|
|
196
|
+
metric_readers=[
|
|
197
|
+
PeriodicExportingMetricReader(
|
|
198
|
+
OTLPMetricExporter(**metric_options),
|
|
199
|
+
export_interval_millis=export_interval_millis,
|
|
200
|
+
)
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
trace.set_tracer_provider(tracer_provider)
|
|
205
|
+
metrics.set_meter_provider(meter_provider)
|
|
206
|
+
|
|
207
|
+
if fastapi_app is not None:
|
|
208
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
209
|
+
|
|
210
|
+
FastAPIInstrumentor.instrument_app(
|
|
211
|
+
fastapi_app,
|
|
212
|
+
tracer_provider=tracer_provider,
|
|
213
|
+
meter_provider=meter_provider,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if instrument_httpx:
|
|
217
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
218
|
+
|
|
219
|
+
HTTPXClientInstrumentor().instrument(
|
|
220
|
+
tracer_provider=tracer_provider,
|
|
221
|
+
meter_provider=meter_provider,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return OpenTelemetryRuntime(
|
|
225
|
+
telemetry=OpenTelemetryTelemetry(
|
|
226
|
+
tracer_provider=tracer_provider,
|
|
227
|
+
meter_provider=meter_provider,
|
|
228
|
+
),
|
|
229
|
+
tracer_provider=tracer_provider,
|
|
230
|
+
meter_provider=meter_provider,
|
|
231
|
+
)
|