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.
@@ -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
+ )