rococo 1.3.1__tar.gz → 1.3.3__tar.gz
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.
- {rococo-1.3.1 → rococo-1.3.3}/PKG-INFO +50 -1
- {rococo-1.3.1 → rococo-1.3.3}/README.md +8 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/postgresql.py +2 -2
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/mailjet.py +10 -4
- rococo-1.3.3/rococo/observability/__init__.py +16 -0
- rococo-1.3.3/rococo/observability/_extras.py +26 -0
- rococo-1.3.3/rococo/observability/base.py +17 -0
- rococo-1.3.3/rococo/observability/logging/open_observe_handler.py +128 -0
- rococo-1.3.3/rococo/observability/open_observe.py +333 -0
- rococo-1.3.3/rococo/observability/tracing/decorators.py +53 -0
- rococo-1.3.3/rococo/observability/tracing/open_observe_tracer.py +59 -0
- rococo-1.3.3/rococo/plugins/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/PKG-INFO +50 -1
- {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/SOURCES.txt +19 -1
- rococo-1.3.3/rococo.egg-info/requires.txt +105 -0
- rococo-1.3.3/setup.py +141 -0
- rococo-1.3.3/tests/__init__.py +0 -0
- rococo-1.3.3/tests/observability/__init__.py +0 -0
- rococo-1.3.3/tests/observability/conftest.py +214 -0
- rococo-1.3.3/tests/observability/test_base.py +122 -0
- rococo-1.3.3/tests/observability/test_extras_contract.py +200 -0
- rococo-1.3.3/tests/observability/test_imports.py +253 -0
- rococo-1.3.3/tests/observability/test_open_observe.py +883 -0
- rococo-1.3.3/tests/observability/test_open_observe_handler.py +400 -0
- rococo-1.3.3/tests/observability/test_open_observe_tracer.py +192 -0
- rococo-1.3.3/tests/observability/test_tracing_decorators.py +204 -0
- rococo-1.3.1/rococo.egg-info/requires.txt +0 -54
- rococo-1.3.1/setup.py +0 -89
- {rococo-1.3.1 → rococo-1.3.3}/LICENSE +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/auth/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/auth/tokens.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/config/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/config/config.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/dynamodb.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/mongodb.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/mysql.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/data/surrealdb.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/config.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/enums.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/factory.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/ses.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/config.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/enums.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/factory.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/ifax.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/rabbitmq.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/sqs.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/cli_base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_runner.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_template.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/sql_migration_base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/cli.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/migration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/cli.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/migration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/cli.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/migration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/email.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/login_method.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/organization.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/otp_method.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/person.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/person_organization_role.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/recovery_code.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/email.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/login_method.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/organization.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/otp_method.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/person.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/person_organization_role.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/surreal_versioned_model.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/models/versioned_model.py +0 -0
- {rococo-1.3.1/rococo/plugins → rococo-1.3.3/rococo/observability/logging}/__init__.py +0 -0
- {rococo-1.3.1/tests → rococo-1.3.3/rococo/observability/tracing}/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/plugins/pooled_connection.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/base_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/dynamodb/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/dynamodb/dynamodb_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mongodb/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mongodb/mongodb_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/mysql_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/organization_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/postgresql/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/postgresql/postgresql_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/organization_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/person_organization_role_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/surreal_db_repository.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/base.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/config.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/enums.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/factory.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/twilio.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/dependency_links.txt +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/entry_points.txt +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/top_level.txt +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/setup.cfg +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/base_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/config_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/__init__.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/conftest.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_dynamodb_integration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_models.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_mongodb_integration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_mysql_integration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_postgres_integration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_surrealdb_integration.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/dynamodb_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/migration_cli_base_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/migration_runner_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/model_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/mongodb_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/mysql_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/postgre_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/sql_migration_base_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/surrealdb_repository_test.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/test_calculated_properties_fix.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/test_faxing.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/test_mailjet_addresses.py +0 -0
- {rococo-1.3.1 → rococo-1.3.3}/tests/test_non_versioned_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rococo
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
4
4
|
Summary: A Python library to help build things the way we want them built
|
|
5
5
|
Home-page: https://github.com/EcorRouge/rococo
|
|
6
6
|
Author: Jay Grieves
|
|
@@ -39,6 +39,35 @@ Requires-Dist: PyMySQL<1.2,>=1.1.1; extra == "data"
|
|
|
39
39
|
Requires-Dist: PyMongo<5.0,>=4.6.3; extra == "data"
|
|
40
40
|
Requires-Dist: psycopg2-binary<3.0,>=2.9.10; extra == "data"
|
|
41
41
|
Requires-Dist: pynamodb<7.0,>=6.0.0; extra == "data"
|
|
42
|
+
Provides-Extra: observability
|
|
43
|
+
Requires-Dist: requests<3.0,>=2.31.0; extra == "observability"
|
|
44
|
+
Requires-Dist: opentelemetry-api<2.0,>=1.24; extra == "observability"
|
|
45
|
+
Provides-Extra: observability-tracing-core
|
|
46
|
+
Requires-Dist: opentelemetry-sdk<2.0,>=1.24; extra == "observability-tracing-core"
|
|
47
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.24; extra == "observability-tracing-core"
|
|
48
|
+
Provides-Extra: observability-flask-tracing
|
|
49
|
+
Requires-Dist: opentelemetry-instrumentation-flask<1.0,>=0.45b0; extra == "observability-flask-tracing"
|
|
50
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "observability-flask-tracing"
|
|
51
|
+
Requires-Dist: Werkzeug<4.0,>=2.0; extra == "observability-flask-tracing"
|
|
52
|
+
Provides-Extra: observability-fastapi-tracing
|
|
53
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi<1.0,>=0.45b0; extra == "observability-fastapi-tracing"
|
|
54
|
+
Requires-Dist: opentelemetry-instrumentation-httpx<1.0,>=0.45b0; extra == "observability-fastapi-tracing"
|
|
55
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "observability-fastapi-tracing"
|
|
56
|
+
Provides-Extra: observability-postgres-tracing
|
|
57
|
+
Requires-Dist: opentelemetry-instrumentation-psycopg2<1.0,>=0.45b0; extra == "observability-postgres-tracing"
|
|
58
|
+
Provides-Extra: observability-langgraph-tracing
|
|
59
|
+
Requires-Dist: openinference-instrumentation-langchain<0.2.0,>=0.1.67; python_version < "3.15" and extra == "observability-langgraph-tracing"
|
|
60
|
+
Provides-Extra: observability-tracing
|
|
61
|
+
Requires-Dist: opentelemetry-sdk<2.0,>=1.24; extra == "observability-tracing"
|
|
62
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.24; extra == "observability-tracing"
|
|
63
|
+
Requires-Dist: opentelemetry-instrumentation-flask<1.0,>=0.45b0; extra == "observability-tracing"
|
|
64
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "observability-tracing"
|
|
65
|
+
Requires-Dist: Werkzeug<4.0,>=2.0; extra == "observability-tracing"
|
|
66
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi<1.0,>=0.45b0; extra == "observability-tracing"
|
|
67
|
+
Requires-Dist: opentelemetry-instrumentation-httpx<1.0,>=0.45b0; extra == "observability-tracing"
|
|
68
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "observability-tracing"
|
|
69
|
+
Requires-Dist: opentelemetry-instrumentation-psycopg2<1.0,>=0.45b0; extra == "observability-tracing"
|
|
70
|
+
Requires-Dist: openinference-instrumentation-langchain<0.2.0,>=0.1.67; python_version < "3.15" and extra == "observability-tracing"
|
|
42
71
|
Provides-Extra: all
|
|
43
72
|
Requires-Dist: DBUtils<4.0,>=3.1; extra == "all"
|
|
44
73
|
Requires-Dist: surrealdb<1.1,>=1.0.7; extra == "all"
|
|
@@ -51,6 +80,18 @@ Requires-Dist: pika<1.4,>=1.3.2; extra == "all"
|
|
|
51
80
|
Requires-Dist: requests<3.0,>=2.31.0; extra == "all"
|
|
52
81
|
Requires-Dist: twilio<10.0,>=9.8.6; extra == "all"
|
|
53
82
|
Requires-Dist: jinja2<4.0,>=3.1.6; extra == "all"
|
|
83
|
+
Requires-Dist: requests<3.0,>=2.31.0; extra == "all"
|
|
84
|
+
Requires-Dist: opentelemetry-api<2.0,>=1.24; extra == "all"
|
|
85
|
+
Requires-Dist: opentelemetry-sdk<2.0,>=1.24; extra == "all"
|
|
86
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.24; extra == "all"
|
|
87
|
+
Requires-Dist: opentelemetry-instrumentation-flask<1.0,>=0.45b0; extra == "all"
|
|
88
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "all"
|
|
89
|
+
Requires-Dist: Werkzeug<4.0,>=2.0; extra == "all"
|
|
90
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi<1.0,>=0.45b0; extra == "all"
|
|
91
|
+
Requires-Dist: opentelemetry-instrumentation-httpx<1.0,>=0.45b0; extra == "all"
|
|
92
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.45b0; extra == "all"
|
|
93
|
+
Requires-Dist: opentelemetry-instrumentation-psycopg2<1.0,>=0.45b0; extra == "all"
|
|
94
|
+
Requires-Dist: openinference-instrumentation-langchain<0.2.0,>=0.1.67; python_version < "3.15" and extra == "all"
|
|
54
95
|
Dynamic: author
|
|
55
96
|
Dynamic: author-email
|
|
56
97
|
Dynamic: description
|
|
@@ -166,10 +207,18 @@ The following extras are available:
|
|
|
166
207
|
- **data-mysql** - MySQL support
|
|
167
208
|
- **data-mongo** - MongoDB support
|
|
168
209
|
- **data-postgres** - PostgreSQL support
|
|
210
|
+
- **data-dynamodb** - DynamoDB support
|
|
169
211
|
- **messaging** - rabbitmq/sqs messaging
|
|
170
212
|
- **emailing** - mailjet/ses emailing
|
|
171
213
|
- **faxing** - iFax faxing support
|
|
172
214
|
- **sms** - Twilio sms support
|
|
215
|
+
- **observability** - base observability: OpenObserve logging handler plus the OpenTelemetry API used for trace correlation and the `@traced`/`traced_step` decorators
|
|
216
|
+
- **observability-tracing-core** - OpenTelemetry SDK and OTLP/HTTP exporter, required by anything calling `get_tracer_provider()`
|
|
217
|
+
- **observability-flask-tracing** - Flask + requests instrumentation, and Werkzeug for streaming-response detection
|
|
218
|
+
- **observability-fastapi-tracing** - FastAPI + httpx + requests instrumentation
|
|
219
|
+
- **observability-postgres-tracing** - psycopg2 instrumentation (query spans)
|
|
220
|
+
- **observability-langgraph-tracing** - LangChain/LangGraph instrumentation via OpenInference
|
|
221
|
+
- **observability-tracing** - all tracing extras (core plus every framework instrumentation above)
|
|
173
222
|
|
|
174
223
|
### Example
|
|
175
224
|
|
|
@@ -101,10 +101,18 @@ The following extras are available:
|
|
|
101
101
|
- **data-mysql** - MySQL support
|
|
102
102
|
- **data-mongo** - MongoDB support
|
|
103
103
|
- **data-postgres** - PostgreSQL support
|
|
104
|
+
- **data-dynamodb** - DynamoDB support
|
|
104
105
|
- **messaging** - rabbitmq/sqs messaging
|
|
105
106
|
- **emailing** - mailjet/ses emailing
|
|
106
107
|
- **faxing** - iFax faxing support
|
|
107
108
|
- **sms** - Twilio sms support
|
|
109
|
+
- **observability** - base observability: OpenObserve logging handler plus the OpenTelemetry API used for trace correlation and the `@traced`/`traced_step` decorators
|
|
110
|
+
- **observability-tracing-core** - OpenTelemetry SDK and OTLP/HTTP exporter, required by anything calling `get_tracer_provider()`
|
|
111
|
+
- **observability-flask-tracing** - Flask + requests instrumentation, and Werkzeug for streaming-response detection
|
|
112
|
+
- **observability-fastapi-tracing** - FastAPI + httpx + requests instrumentation
|
|
113
|
+
- **observability-postgres-tracing** - psycopg2 instrumentation (query spans)
|
|
114
|
+
- **observability-langgraph-tracing** - LangChain/LangGraph instrumentation via OpenInference
|
|
115
|
+
- **observability-tracing** - all tracing extras (core plus every framework instrumentation above)
|
|
108
116
|
|
|
109
117
|
### Example
|
|
110
118
|
|
|
@@ -419,8 +419,8 @@ class PostgreSQLAdapter(DbAdapter):
|
|
|
419
419
|
return True
|
|
420
420
|
except psycopg2.Error as ex:
|
|
421
421
|
self._connection.rollback()
|
|
422
|
-
if retry_count < 3 and ex
|
|
423
|
-
# Deadlock detected
|
|
422
|
+
if retry_count < 3 and getattr(ex, 'pgcode', None) == '40P01':
|
|
423
|
+
# Deadlock detected (PostgreSQL SQLSTATE 40P01)
|
|
424
424
|
logging.warning("Deadlock detected on table %s. Retrying in %d seconds. Attempt %d",
|
|
425
425
|
table_name, 2**retry_count, retry_count+1)
|
|
426
426
|
time.sleep(2**retry_count)
|
|
@@ -71,8 +71,14 @@ class MailjetService(EmailService):
|
|
|
71
71
|
def __call__(self, config: MailjetConfig, *args, **kwargs):
|
|
72
72
|
super().__call__(config)
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
# Parse SOURCE_EMAIL which accepts two formats:
|
|
75
|
+
# "Name <email@example.com>" → {"Name": "Name", "Email": "email@example.com"}
|
|
76
|
+
# "user@example.com" → {"Name": "", "Email": "user@example.com"}
|
|
77
|
+
match = re.match(r'^\s*(.*?)\s*<(.+)>\s*$', self.config.SOURCE_EMAIL)
|
|
78
|
+
if match:
|
|
79
|
+
name, email = match.groups()
|
|
80
|
+
else:
|
|
81
|
+
name, email = '', self.config.SOURCE_EMAIL.strip()
|
|
76
82
|
self.from_address = {"Name": name, "Email": email}
|
|
77
83
|
|
|
78
84
|
self.client = Client(
|
|
@@ -152,7 +158,7 @@ class MailjetService(EmailService):
|
|
|
152
158
|
response_data = result.json()["Data"]
|
|
153
159
|
except Exception as e:
|
|
154
160
|
message = f"Couldn't find Mailjet contact for {email}: {e}"
|
|
155
|
-
logger.exception(message
|
|
161
|
+
logger.exception(message)
|
|
156
162
|
return
|
|
157
163
|
|
|
158
164
|
for contact in response_data:
|
|
@@ -163,4 +169,4 @@ class MailjetService(EmailService):
|
|
|
163
169
|
self.config.MAILJET_API_KEY, self.config.MAILJET_API_SECRET), timeout=15)
|
|
164
170
|
except Exception as e:
|
|
165
171
|
message = f"Couldn't remove Mailjet contact for {email} : {e}"
|
|
166
|
-
logger.exception(message
|
|
172
|
+
logger.exception(message)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# rococo/observability/__init__.py
|
|
2
|
+
from rococo.observability.open_observe import OpenObserve
|
|
3
|
+
# from rococo.observability.datadog import Datadog # future provider, same shape
|
|
4
|
+
|
|
5
|
+
PROVIDERS = {
|
|
6
|
+
"open_observe": OpenObserve,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_observability_provider(name):
|
|
11
|
+
try:
|
|
12
|
+
return PROVIDERS[name]
|
|
13
|
+
except KeyError:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"Unknown observability provider: {name!r}. Available: {list(PROVIDERS)}"
|
|
16
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers for turning missing optional dependencies into clear, actionable errors.
|
|
3
|
+
|
|
4
|
+
The observability module's third-party dependencies are all optional extras (see
|
|
5
|
+
``extras_require`` in ``setup.py``). Whenever one of those imports fails, we
|
|
6
|
+
re-raise as an ``ImportError`` that names the exact extra to install, rather than
|
|
7
|
+
leaking a bare ``No module named ...`` that gives the caller no idea which
|
|
8
|
+
``pip install 'rococo[...]'`` would fix it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def raise_missing_extra(extra, error):
|
|
13
|
+
"""
|
|
14
|
+
Re-raise a failed optional import as a descriptive ``ImportError``.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
extra: The name of the extra in ``setup.py`` that provides the missing
|
|
18
|
+
dependency (e.g. ``"observability-flask-tracing"``).
|
|
19
|
+
error: The original ``ImportError`` that was caught.
|
|
20
|
+
"""
|
|
21
|
+
raise ImportError(
|
|
22
|
+
f"This feature of rococo.observability requires the optional "
|
|
23
|
+
f"'{extra}' dependencies, which are not installed. Install them with:\n\n"
|
|
24
|
+
f" pip install 'rococo[{extra}]'\n\n"
|
|
25
|
+
f"(underlying import error: {error})"
|
|
26
|
+
) from error
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
class ObservabilityBase:
|
|
3
|
+
def __init__(self, **config_kwargs):
|
|
4
|
+
self.config = config_kwargs
|
|
5
|
+
self._validate_required_config()
|
|
6
|
+
self.setup()
|
|
7
|
+
|
|
8
|
+
def setup(self):
|
|
9
|
+
raise NotImplementedError("Subclasses must implement the setup method.")
|
|
10
|
+
|
|
11
|
+
def _validate_required_config(self):
|
|
12
|
+
missing = [key for key in self.REQUIRED_CONFIG_KEYS if not self.config.get(key)]
|
|
13
|
+
if missing:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"{self.__class__.__name__} configuration is incomplete. "
|
|
16
|
+
f"Missing required key(s): {', '.join(missing)}."
|
|
17
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import base64
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
from .._extras import raise_missing_extra
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import requests
|
|
13
|
+
except ImportError as e:
|
|
14
|
+
# 'requests' is the hard dependency of the base logging handler's transport.
|
|
15
|
+
raise_missing_extra("observability", e)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from opentelemetry import trace
|
|
19
|
+
except ImportError as e:
|
|
20
|
+
# Every emitted record is correlated with the active trace, so the
|
|
21
|
+
# opentelemetry API is a hard requirement too (it ships in the same base
|
|
22
|
+
# 'observability' extra). Fail here, at import time, rather than from
|
|
23
|
+
# inside emit() — handler errors raised during logging are swallowed by
|
|
24
|
+
# logging.Handler.handleError in some configurations.
|
|
25
|
+
raise_missing_extra("observability", e)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OpenObserveHandler(logging.Handler):
|
|
29
|
+
"""
|
|
30
|
+
Ships log records to an OpenObserve logs stream via HTTP ingestion.
|
|
31
|
+
|
|
32
|
+
Non-blocking by design: emit() only builds the log entry and pushes it
|
|
33
|
+
onto an in-memory queue — a single background thread drains the queue
|
|
34
|
+
and does the actual HTTP POST (batched), so callers never block on
|
|
35
|
+
network I/O. Mirrors pyrollbar's default 'thread' handler behavior.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, base_url, org_id, ingestion_token, env, stream="app-logs",
|
|
39
|
+
service_name=None, timeout=2, queue_maxsize=10_000, batch_size=50):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.url = urljoin(
|
|
42
|
+
base_url.rstrip("/") + "/", # ensure exactly one trailing slash
|
|
43
|
+
f"api/{org_id}/{stream}/_json", # no leading slash — relative to base
|
|
44
|
+
)
|
|
45
|
+
self.auth_header = "Basic " + base64.b64encode((org_id + ':' + ingestion_token).encode()).decode()
|
|
46
|
+
self.env = env
|
|
47
|
+
self.service_name = service_name
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.batch_size = batch_size
|
|
50
|
+
|
|
51
|
+
self._queue = queue.Queue(maxsize=queue_maxsize)
|
|
52
|
+
self._stop_event = threading.Event()
|
|
53
|
+
self._worker = threading.Thread(
|
|
54
|
+
target=self._process_queue,
|
|
55
|
+
name="OpenObserveHandler-sender",
|
|
56
|
+
daemon=True,
|
|
57
|
+
)
|
|
58
|
+
self._worker.start()
|
|
59
|
+
|
|
60
|
+
# Ensure a normal process exit gives the queue a chance to drain
|
|
61
|
+
atexit.register(self.close)
|
|
62
|
+
|
|
63
|
+
def emit(self, record):
|
|
64
|
+
log_entry = {
|
|
65
|
+
"_timestamp": int(time.time() * 1_000_000),
|
|
66
|
+
"level": record.levelname,
|
|
67
|
+
"message": record.getMessage(),
|
|
68
|
+
"env": self.env,
|
|
69
|
+
}
|
|
70
|
+
if self.service_name:
|
|
71
|
+
log_entry["service"] = self.service_name
|
|
72
|
+
if record.exc_info:
|
|
73
|
+
log_entry["stack_trace"] = self.format(record)
|
|
74
|
+
|
|
75
|
+
# Correlate with the active trace, if one exists.
|
|
76
|
+
span = trace.get_current_span()
|
|
77
|
+
span_context = span.get_span_context()
|
|
78
|
+
if span_context.is_valid:
|
|
79
|
+
log_entry["trace_id"] = format(span_context.trace_id, "032x")
|
|
80
|
+
log_entry["span_id"] = format(span_context.span_id, "016x")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
self._queue.put_nowait(log_entry)
|
|
84
|
+
except queue.Full:
|
|
85
|
+
# Queue backed up (OpenObserve unreachable/slow for a while) —
|
|
86
|
+
# drop rather than block the caller.
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def _process_queue(self):
|
|
90
|
+
"""Runs on the background thread: drains the queue, sends batches."""
|
|
91
|
+
while not self._stop_event.is_set() or not self._queue.empty():
|
|
92
|
+
batch = self._drain_batch()
|
|
93
|
+
if batch:
|
|
94
|
+
self._send(batch)
|
|
95
|
+
else:
|
|
96
|
+
time.sleep(0.1) # nothing to do — avoid a busy loop
|
|
97
|
+
|
|
98
|
+
def _drain_batch(self):
|
|
99
|
+
"""Pulls up to self.batch_size entries currently on the queue, non-blocking."""
|
|
100
|
+
batch = []
|
|
101
|
+
try:
|
|
102
|
+
while len(batch) < self.batch_size:
|
|
103
|
+
batch.append(self._queue.get_nowait())
|
|
104
|
+
except queue.Empty:
|
|
105
|
+
pass
|
|
106
|
+
return batch
|
|
107
|
+
|
|
108
|
+
def _send(self, batch):
|
|
109
|
+
try:
|
|
110
|
+
requests.post(
|
|
111
|
+
self.url,
|
|
112
|
+
json=batch,
|
|
113
|
+
headers={"Authorization": self.auth_header, "Content-Type": "application/json"},
|
|
114
|
+
timeout=self.timeout,
|
|
115
|
+
)
|
|
116
|
+
except requests.RequestException:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def close(self):
|
|
120
|
+
"""
|
|
121
|
+
Signals the background thread to finish draining and stop, then
|
|
122
|
+
waits briefly for it. Registered via atexit so a normal process
|
|
123
|
+
exit flushes pending logs; a hard kill (SIGKILL) still loses
|
|
124
|
+
whatever's in the queue at that instant, same as any in-memory queue.
|
|
125
|
+
"""
|
|
126
|
+
self._stop_event.set()
|
|
127
|
+
self._worker.join(timeout=5)
|
|
128
|
+
super().close()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
|
|
2
|
+
from .base import ObservabilityBase
|
|
3
|
+
from .logging.open_observe_handler import OpenObserveHandler
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenObserve(ObservabilityBase):
|
|
7
|
+
"""
|
|
8
|
+
OpenObserve integration for logging and tracing.
|
|
9
|
+
|
|
10
|
+
Required config kwargs:
|
|
11
|
+
OO_BASE_URL (str): Base URL of the OpenObserve instance, e.g. "http://host:5080".
|
|
12
|
+
OO_ORG_ID (str): Organization identifier (the slug, not the display name).
|
|
13
|
+
OO_INGESTION_TOKEN (str): Ingestion token for this org, from the Ingestion page.
|
|
14
|
+
|
|
15
|
+
Optional config kwargs:
|
|
16
|
+
SERVICE_NAME (str): Used to tag logs/traces with `service`/`service.name`.
|
|
17
|
+
If unset, tracing resource attributes and log tagging are skipped.
|
|
18
|
+
APP_ENV (str): Environment label (dev/staging/production), tagged on
|
|
19
|
+
logs and traces. Defaults to no `env` field if unset.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
REQUIRED_CONFIG_KEYS = ("OO_BASE_URL", "OO_ORG_ID", "OO_INGESTION_TOKEN")
|
|
23
|
+
|
|
24
|
+
def setup(self):
|
|
25
|
+
# config
|
|
26
|
+
self.oo_base_url = self.config.get("OO_BASE_URL")
|
|
27
|
+
self.oo_org_id = self.config.get("OO_ORG_ID")
|
|
28
|
+
self.oo_ingestion_token = self.config.get("OO_INGESTION_TOKEN")
|
|
29
|
+
self.service_name = self.config.get("SERVICE_NAME")
|
|
30
|
+
if not all([self.oo_base_url, self.oo_org_id, self.oo_ingestion_token]):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"OpenObserve configuration is incomplete. Please set OO_BASE_URL, OO_ORG_ID, and OO_INGESTION_TOKEN."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# state
|
|
36
|
+
self._requests_instrumented = False
|
|
37
|
+
self._psycopg2_instrumented = False
|
|
38
|
+
self._httpx_instrumented = False
|
|
39
|
+
self._langgraph_instrumented = False
|
|
40
|
+
self._tracer_provider_registered = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_stream(self, stream_name):
|
|
44
|
+
return f"{stream_name}"
|
|
45
|
+
|
|
46
|
+
def get_logging_handler(self, env=None, stream=None):
|
|
47
|
+
return OpenObserveHandler(
|
|
48
|
+
base_url=self.oo_base_url,
|
|
49
|
+
org_id=self.oo_org_id,
|
|
50
|
+
ingestion_token=self.oo_ingestion_token,
|
|
51
|
+
env=env or self.config.get("APP_ENV"),
|
|
52
|
+
stream=stream or self._get_stream('logs'),
|
|
53
|
+
service_name=self.service_name,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get_tracer_provider(self, env=None, service_name=None, stream=None, register=True):
|
|
57
|
+
# OpenObserveTracer itself needs opentelemetry-sdk + otlp exporter —
|
|
58
|
+
# these ARE core requirements for any service doing tracing at all,
|
|
59
|
+
# so this import can stay at module level in open_observe.py/open_observe_tracer.py
|
|
60
|
+
from .tracing.open_observe_tracer import OpenObserveTracer
|
|
61
|
+
|
|
62
|
+
tracer = OpenObserveTracer(
|
|
63
|
+
url=self.oo_base_url,
|
|
64
|
+
org_id=self.oo_org_id,
|
|
65
|
+
ingestion_token=self.oo_ingestion_token,
|
|
66
|
+
service_name=service_name or self.service_name,
|
|
67
|
+
env=env or self.config.get("APP_ENV"),
|
|
68
|
+
stream=stream or self._get_stream("traces"),
|
|
69
|
+
register=register and not self._tracer_provider_registered,
|
|
70
|
+
)
|
|
71
|
+
if register:
|
|
72
|
+
self._tracer_provider_registered = True
|
|
73
|
+
return tracer.provider
|
|
74
|
+
|
|
75
|
+
def enable_flask_tracing(self, app, excluded_urls="^/$,^/health$"):
|
|
76
|
+
"""
|
|
77
|
+
One-call setup for Flask apps: registers the tracer provider (if not
|
|
78
|
+
already registered), instruments Flask + requests, and wraps every
|
|
79
|
+
view function in its own span — including streaming/SSE views, where
|
|
80
|
+
the span stays open for the full duration of response iteration
|
|
81
|
+
(not just the view function's initial return), so persistence logic
|
|
82
|
+
that runs during/after streaming still nests correctly.
|
|
83
|
+
|
|
84
|
+
App code only ever needs to call this single method.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
|
|
88
|
+
from opentelemetry.instrumentation.requests import RequestsInstrumentor
|
|
89
|
+
except ImportError as e:
|
|
90
|
+
from ._extras import raise_missing_extra
|
|
91
|
+
raise_missing_extra("observability-flask-tracing", e)
|
|
92
|
+
|
|
93
|
+
self.get_tracer_provider() # no-ops registration if already done
|
|
94
|
+
|
|
95
|
+
FlaskInstrumentor().instrument_app(
|
|
96
|
+
app,
|
|
97
|
+
excluded_urls=excluded_urls # regex — excludes exact root path "/" and "/health"
|
|
98
|
+
)
|
|
99
|
+
if not self._requests_instrumented:
|
|
100
|
+
RequestsInstrumentor().instrument(
|
|
101
|
+
excluded_urls=self.oo_base_url # regex-matched — excludes calls to OpenObserve itself
|
|
102
|
+
)
|
|
103
|
+
self._requests_instrumented = True
|
|
104
|
+
|
|
105
|
+
self._wrap_view_functions(app)
|
|
106
|
+
|
|
107
|
+
def _wrap_view_functions(self, app, excluded_endpoints=None):
|
|
108
|
+
"""
|
|
109
|
+
Wraps every registered Flask view function in its own span, named
|
|
110
|
+
after the Python function. Must run AFTER all blueprints/routes are
|
|
111
|
+
registered, since app.view_functions is only fully populated by then.
|
|
112
|
+
"""
|
|
113
|
+
from opentelemetry import trace
|
|
114
|
+
excluded_endpoints = excluded_endpoints or set()
|
|
115
|
+
tracer = trace.get_tracer(__name__)
|
|
116
|
+
|
|
117
|
+
for endpoint, view_func in app.view_functions.items():
|
|
118
|
+
if endpoint in excluded_endpoints:
|
|
119
|
+
continue
|
|
120
|
+
app.view_functions[endpoint] = self._traced_view(tracer, view_func)
|
|
121
|
+
|
|
122
|
+
def _traced_view(self, tracer, view_func):
|
|
123
|
+
"""
|
|
124
|
+
Wraps a single view function. Detects Werkzeug streaming responses
|
|
125
|
+
(SSE, chunked, etc.) and keeps the span open across the full body
|
|
126
|
+
iteration rather than closing it the instant the view returns the
|
|
127
|
+
Response object — otherwise any work that happens while the stream
|
|
128
|
+
is being consumed (e.g. persistence at the end of an SSE generator)
|
|
129
|
+
runs with no active span/parent context at all.
|
|
130
|
+
"""
|
|
131
|
+
from opentelemetry import trace, context as otel_context
|
|
132
|
+
from functools import wraps
|
|
133
|
+
try:
|
|
134
|
+
from werkzeug.wrappers import Response
|
|
135
|
+
except ImportError as e:
|
|
136
|
+
from ._extras import raise_missing_extra
|
|
137
|
+
raise_missing_extra("observability-flask-tracing", e)
|
|
138
|
+
|
|
139
|
+
span_name = f"{view_func.__module__}.{view_func.__name__}"
|
|
140
|
+
|
|
141
|
+
@wraps(view_func)
|
|
142
|
+
def wrapper(*args, **kwargs):
|
|
143
|
+
span = tracer.start_span(span_name)
|
|
144
|
+
ctx = trace.set_span_in_context(span)
|
|
145
|
+
token = otel_context.attach(ctx)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = view_func(*args, **kwargs)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
span.record_exception(e)
|
|
151
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
|
|
152
|
+
otel_context.detach(token)
|
|
153
|
+
span.end()
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
if isinstance(result, Response) and result.is_streamed:
|
|
157
|
+
# Real work may still be happening (streaming body, and any
|
|
158
|
+
# persistence logic run at the end of the generator) — keep
|
|
159
|
+
# the span alive across that, not just the view's return.
|
|
160
|
+
otel_context.detach(token)
|
|
161
|
+
result.response = self._traced_stream(tracer, span, ctx, result.response)
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
# Normal, non-streaming view — span covers exactly this call
|
|
165
|
+
otel_context.detach(token)
|
|
166
|
+
span.end()
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
return wrapper
|
|
170
|
+
|
|
171
|
+
def _traced_stream(self, tracer, span, ctx, body_iter):
|
|
172
|
+
from opentelemetry import trace, context as otel_context
|
|
173
|
+
|
|
174
|
+
it = iter(body_iter)
|
|
175
|
+
try:
|
|
176
|
+
while True:
|
|
177
|
+
token = otel_context.attach(ctx)
|
|
178
|
+
try:
|
|
179
|
+
chunk = next(it) # the real work happens HERE — now correctly inside the attached context
|
|
180
|
+
finally:
|
|
181
|
+
otel_context.detach(token)
|
|
182
|
+
yield chunk
|
|
183
|
+
except StopIteration:
|
|
184
|
+
return
|
|
185
|
+
except Exception as e:
|
|
186
|
+
span.record_exception(e)
|
|
187
|
+
span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
|
|
188
|
+
raise
|
|
189
|
+
finally:
|
|
190
|
+
# Client disconnect (GeneratorExit) closes THIS generator at its
|
|
191
|
+
# last `yield` — but that does NOT automatically close `it`.
|
|
192
|
+
# Close it explicitly, with context attached, so any cleanup logic
|
|
193
|
+
# in stream_chat_message (e.g. _persist_interrupted_stream) also
|
|
194
|
+
# runs under the correct span.
|
|
195
|
+
if hasattr(it, "close"):
|
|
196
|
+
token = otel_context.attach(ctx)
|
|
197
|
+
try:
|
|
198
|
+
it.close()
|
|
199
|
+
finally:
|
|
200
|
+
otel_context.detach(token)
|
|
201
|
+
span.end()
|
|
202
|
+
|
|
203
|
+
def enable_fastapi_tracing(self, app, excluded_urls="^/$,^/health$"):
|
|
204
|
+
"""
|
|
205
|
+
One-call setup for FastAPI apps: registers the tracer provider (if
|
|
206
|
+
not already registered) and instruments FastAPI + httpx. App code
|
|
207
|
+
only ever needs to call this single method, once, at app startup.
|
|
208
|
+
|
|
209
|
+
excluded_urls: comma-separated regex patterns for paths to skip
|
|
210
|
+
(defaults to root "/" and "/health" — typical health-check noise).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
# Lazy imports — only services that call this need
|
|
214
|
+
# opentelemetry-instrumentation-fastapi/-httpx installed
|
|
215
|
+
try:
|
|
216
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
217
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
218
|
+
except ImportError as e:
|
|
219
|
+
from ._extras import raise_missing_extra
|
|
220
|
+
raise_missing_extra("observability-fastapi-tracing", e)
|
|
221
|
+
|
|
222
|
+
self.get_tracer_provider() # no-ops registration if already done
|
|
223
|
+
|
|
224
|
+
FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls)
|
|
225
|
+
|
|
226
|
+
if not self._httpx_instrumented:
|
|
227
|
+
HTTPXClientInstrumentor().instrument()
|
|
228
|
+
self._httpx_instrumented = True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def enable_postgres_tracing(self):
|
|
232
|
+
"""
|
|
233
|
+
Patches psycopg2 globally so every query run through it produces a
|
|
234
|
+
span automatically — no changes needed in adapter/repository code.
|
|
235
|
+
|
|
236
|
+
Call this exactly once per process, as early as possible, before any
|
|
237
|
+
psycopg2 connection is created (e.g. from RepositoryFactory.__init__,
|
|
238
|
+
guarded by a class-level flag + lock — see the calling pattern used
|
|
239
|
+
there). Safe to call more than once; only instruments the first time.
|
|
240
|
+
"""
|
|
241
|
+
# Lazy import — only services that call this need
|
|
242
|
+
# opentelemetry-instrumentation-psycopg2 installed
|
|
243
|
+
try:
|
|
244
|
+
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
|
|
245
|
+
except ImportError as e:
|
|
246
|
+
from ._extras import raise_missing_extra
|
|
247
|
+
raise_missing_extra("observability-postgres-tracing", e)
|
|
248
|
+
|
|
249
|
+
if not self._psycopg2_instrumented:
|
|
250
|
+
Psycopg2Instrumentor().instrument(
|
|
251
|
+
enable_commenter=True, # tags SQL with trace context as a
|
|
252
|
+
# comment — lets you correlate a slow
|
|
253
|
+
# query in Postgres logs/pg_stat_statements
|
|
254
|
+
# back to the exact trace that issued it
|
|
255
|
+
commenter_options={},
|
|
256
|
+
)
|
|
257
|
+
self._psycopg2_instrumented = True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def enable_langgraph_tracing(self):
|
|
261
|
+
"""
|
|
262
|
+
Instruments LangChain/LangGraph via OpenInference — hooks into
|
|
263
|
+
langchain-core, the shared foundation underlying LangGraph, so this
|
|
264
|
+
covers node/edge execution, chain/tool calls, and LLM invocations
|
|
265
|
+
automatically, with zero changes needed in chatbot.py.
|
|
266
|
+
|
|
267
|
+
Spans are emitted through whatever tracer provider is already
|
|
268
|
+
globally registered — call get_tracer_provider() / enable_fastapi_tracing()
|
|
269
|
+
/ enable_flask_tracing() first, so LangGraph spans nest correctly
|
|
270
|
+
under the active request span rather than being orphaned.
|
|
271
|
+
|
|
272
|
+
Safe to call multiple times; only instruments once per process.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
# Lazy import — only services that call this need
|
|
276
|
+
# openinference-instrumentation-langchain installed
|
|
277
|
+
try:
|
|
278
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
|
279
|
+
except ImportError as e:
|
|
280
|
+
from ._extras import raise_missing_extra
|
|
281
|
+
raise_missing_extra("observability-langgraph-tracing", e)
|
|
282
|
+
|
|
283
|
+
if not self._langgraph_instrumented:
|
|
284
|
+
LangChainInstrumentor().instrument()
|
|
285
|
+
self._langgraph_instrumented = True
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def enable_class_tracing(self, cls, skip_methods=None, name_prefix=None, include_subclasses=True):
|
|
289
|
+
"""
|
|
290
|
+
Wraps every public, callable, non-dunder method defined directly on
|
|
291
|
+
`cls` with the shared @traced decorator. If include_subclasses=True
|
|
292
|
+
(default), also recursively wraps every currently-loaded subclass's
|
|
293
|
+
own directly-defined methods — so calling this once on the top of
|
|
294
|
+
your repository hierarchy covers the whole tree.
|
|
295
|
+
|
|
296
|
+
self.enable_class_tracing(PostgreSQLRepository)
|
|
297
|
+
# traces PostgreSQLRepository, BaseRepository, and every
|
|
298
|
+
# concrete repo (DataImportJobRepository, etc.) in one call
|
|
299
|
+
|
|
300
|
+
Note: only classes already imported at call time are visible via
|
|
301
|
+
__subclasses__() — call this after your views/modules that define
|
|
302
|
+
repository subclasses have been imported (e.g. after
|
|
303
|
+
initialize_views(api) in create_app()).
|
|
304
|
+
"""
|
|
305
|
+
# decorators.traced needs only opentelemetry-api (the base extra) —
|
|
306
|
+
# class tracing emits spans on whatever provider is already registered,
|
|
307
|
+
# so it must not drag in the SDK via open_observe_tracer.
|
|
308
|
+
from .tracing.decorators import traced
|
|
309
|
+
import inspect
|
|
310
|
+
|
|
311
|
+
default_skip = {"__init__", "__init_subclass__"}
|
|
312
|
+
skip_methods = default_skip | set(skip_methods or [])
|
|
313
|
+
|
|
314
|
+
self._trace_single_class(cls, skip_methods, name_prefix, traced, inspect)
|
|
315
|
+
|
|
316
|
+
if include_subclasses:
|
|
317
|
+
for subclass in self._all_subclasses(cls):
|
|
318
|
+
self._trace_single_class(subclass, skip_methods, name_prefix, traced, inspect)
|
|
319
|
+
|
|
320
|
+
def _trace_single_class(self, cls, skip_methods, name_prefix, traced, inspect):
|
|
321
|
+
if cls.__dict__.get("_oo_class_traced", False):
|
|
322
|
+
return
|
|
323
|
+
for name, attr in list(vars(cls).items()):
|
|
324
|
+
if name.startswith("_") or name in skip_methods:
|
|
325
|
+
continue
|
|
326
|
+
if inspect.isfunction(attr):
|
|
327
|
+
span_name = f"{name_prefix or cls.__name__}.{name}"
|
|
328
|
+
setattr(cls, name, traced(name=span_name)(attr))
|
|
329
|
+
cls._oo_class_traced = True
|
|
330
|
+
|
|
331
|
+
def _all_subclasses(self, cls):
|
|
332
|
+
direct = set(cls.__subclasses__())
|
|
333
|
+
return direct.union(s for sub in direct for s in self._all_subclasses(sub))
|