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.
Files changed (140) hide show
  1. {rococo-1.3.1 → rococo-1.3.3}/PKG-INFO +50 -1
  2. {rococo-1.3.1 → rococo-1.3.3}/README.md +8 -0
  3. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/postgresql.py +2 -2
  4. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/mailjet.py +10 -4
  5. rococo-1.3.3/rococo/observability/__init__.py +16 -0
  6. rococo-1.3.3/rococo/observability/_extras.py +26 -0
  7. rococo-1.3.3/rococo/observability/base.py +17 -0
  8. rococo-1.3.3/rococo/observability/logging/open_observe_handler.py +128 -0
  9. rococo-1.3.3/rococo/observability/open_observe.py +333 -0
  10. rococo-1.3.3/rococo/observability/tracing/decorators.py +53 -0
  11. rococo-1.3.3/rococo/observability/tracing/open_observe_tracer.py +59 -0
  12. rococo-1.3.3/rococo/plugins/__init__.py +0 -0
  13. {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/PKG-INFO +50 -1
  14. {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/SOURCES.txt +19 -1
  15. rococo-1.3.3/rococo.egg-info/requires.txt +105 -0
  16. rococo-1.3.3/setup.py +141 -0
  17. rococo-1.3.3/tests/__init__.py +0 -0
  18. rococo-1.3.3/tests/observability/__init__.py +0 -0
  19. rococo-1.3.3/tests/observability/conftest.py +214 -0
  20. rococo-1.3.3/tests/observability/test_base.py +122 -0
  21. rococo-1.3.3/tests/observability/test_extras_contract.py +200 -0
  22. rococo-1.3.3/tests/observability/test_imports.py +253 -0
  23. rococo-1.3.3/tests/observability/test_open_observe.py +883 -0
  24. rococo-1.3.3/tests/observability/test_open_observe_handler.py +400 -0
  25. rococo-1.3.3/tests/observability/test_open_observe_tracer.py +192 -0
  26. rococo-1.3.3/tests/observability/test_tracing_decorators.py +204 -0
  27. rococo-1.3.1/rococo.egg-info/requires.txt +0 -54
  28. rococo-1.3.1/setup.py +0 -89
  29. {rococo-1.3.1 → rococo-1.3.3}/LICENSE +0 -0
  30. {rococo-1.3.1 → rococo-1.3.3}/rococo/__init__.py +0 -0
  31. {rococo-1.3.1 → rococo-1.3.3}/rococo/auth/__init__.py +0 -0
  32. {rococo-1.3.1 → rococo-1.3.3}/rococo/auth/tokens.py +0 -0
  33. {rococo-1.3.1 → rococo-1.3.3}/rococo/config/__init__.py +0 -0
  34. {rococo-1.3.1 → rococo-1.3.3}/rococo/config/config.py +0 -0
  35. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/__init__.py +0 -0
  36. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/base.py +0 -0
  37. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/dynamodb.py +0 -0
  38. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/mongodb.py +0 -0
  39. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/mysql.py +0 -0
  40. {rococo-1.3.1 → rococo-1.3.3}/rococo/data/surrealdb.py +0 -0
  41. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/__init__.py +0 -0
  42. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/base.py +0 -0
  43. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/config.py +0 -0
  44. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/enums.py +0 -0
  45. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/factory.py +0 -0
  46. {rococo-1.3.1 → rococo-1.3.3}/rococo/emailing/ses.py +0 -0
  47. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/__init__.py +0 -0
  48. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/base.py +0 -0
  49. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/config.py +0 -0
  50. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/enums.py +0 -0
  51. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/factory.py +0 -0
  52. {rococo-1.3.1 → rococo-1.3.3}/rococo/faxing/ifax.py +0 -0
  53. {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/__init__.py +0 -0
  54. {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/base.py +0 -0
  55. {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/rabbitmq.py +0 -0
  56. {rococo-1.3.1 → rococo-1.3.3}/rococo/messaging/sqs.py +0 -0
  57. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/__init__.py +0 -0
  58. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/__init__.py +0 -0
  59. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/cli_base.py +0 -0
  60. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_base.py +0 -0
  61. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_runner.py +0 -0
  62. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/migration_template.py +0 -0
  63. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/common/sql_migration_base.py +0 -0
  64. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/__init__.py +0 -0
  65. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/cli.py +0 -0
  66. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mongo/migration.py +0 -0
  67. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/__init__.py +0 -0
  68. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/cli.py +0 -0
  69. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/mysql/migration.py +0 -0
  70. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/__init__.py +0 -0
  71. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/cli.py +0 -0
  72. {rococo-1.3.1 → rococo-1.3.3}/rococo/migrations/postgres/migration.py +0 -0
  73. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/__init__.py +0 -0
  74. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/email.py +0 -0
  75. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/login_method.py +0 -0
  76. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/organization.py +0 -0
  77. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/otp_method.py +0 -0
  78. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/person.py +0 -0
  79. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/person_organization_role.py +0 -0
  80. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/recovery_code.py +0 -0
  81. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/__init__.py +0 -0
  82. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/email.py +0 -0
  83. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/login_method.py +0 -0
  84. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/organization.py +0 -0
  85. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/otp_method.py +0 -0
  86. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/person.py +0 -0
  87. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/person_organization_role.py +0 -0
  88. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/surrealdb/surreal_versioned_model.py +0 -0
  89. {rococo-1.3.1 → rococo-1.3.3}/rococo/models/versioned_model.py +0 -0
  90. {rococo-1.3.1/rococo/plugins → rococo-1.3.3/rococo/observability/logging}/__init__.py +0 -0
  91. {rococo-1.3.1/tests → rococo-1.3.3/rococo/observability/tracing}/__init__.py +0 -0
  92. {rococo-1.3.1 → rococo-1.3.3}/rococo/plugins/pooled_connection.py +0 -0
  93. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/__init__.py +0 -0
  94. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/base_repository.py +0 -0
  95. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/dynamodb/__init__.py +0 -0
  96. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/dynamodb/dynamodb_repository.py +0 -0
  97. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mongodb/__init__.py +0 -0
  98. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mongodb/mongodb_repository.py +0 -0
  99. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/__init__.py +0 -0
  100. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/mysql_repository.py +0 -0
  101. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/mysql/organization_repository.py +0 -0
  102. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/postgresql/__init__.py +0 -0
  103. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/postgresql/postgresql_repository.py +0 -0
  104. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/__init__.py +0 -0
  105. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/organization_repository.py +0 -0
  106. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/person_organization_role_repository.py +0 -0
  107. {rococo-1.3.1 → rococo-1.3.3}/rococo/repositories/surrealdb/surreal_db_repository.py +0 -0
  108. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/__init__.py +0 -0
  109. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/base.py +0 -0
  110. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/config.py +0 -0
  111. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/enums.py +0 -0
  112. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/factory.py +0 -0
  113. {rococo-1.3.1 → rococo-1.3.3}/rococo/sms/twilio.py +0 -0
  114. {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/dependency_links.txt +0 -0
  115. {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/entry_points.txt +0 -0
  116. {rococo-1.3.1 → rococo-1.3.3}/rococo.egg-info/top_level.txt +0 -0
  117. {rococo-1.3.1 → rococo-1.3.3}/setup.cfg +0 -0
  118. {rococo-1.3.1 → rococo-1.3.3}/tests/base_repository_test.py +0 -0
  119. {rococo-1.3.1 → rococo-1.3.3}/tests/config_test.py +0 -0
  120. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/__init__.py +0 -0
  121. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/conftest.py +0 -0
  122. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_dynamodb_integration.py +0 -0
  123. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_models.py +0 -0
  124. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_mongodb_integration.py +0 -0
  125. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_mysql_integration.py +0 -0
  126. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_postgres_integration.py +0 -0
  127. {rococo-1.3.1 → rococo-1.3.3}/tests/database-integration/test_surrealdb_integration.py +0 -0
  128. {rococo-1.3.1 → rococo-1.3.3}/tests/dynamodb_repository_test.py +0 -0
  129. {rococo-1.3.1 → rococo-1.3.3}/tests/migration_cli_base_test.py +0 -0
  130. {rococo-1.3.1 → rococo-1.3.3}/tests/migration_runner_test.py +0 -0
  131. {rococo-1.3.1 → rococo-1.3.3}/tests/model_test.py +0 -0
  132. {rococo-1.3.1 → rococo-1.3.3}/tests/mongodb_repository_test.py +0 -0
  133. {rococo-1.3.1 → rococo-1.3.3}/tests/mysql_repository_test.py +0 -0
  134. {rococo-1.3.1 → rococo-1.3.3}/tests/postgre_repository_test.py +0 -0
  135. {rococo-1.3.1 → rococo-1.3.3}/tests/sql_migration_base_test.py +0 -0
  136. {rococo-1.3.1 → rococo-1.3.3}/tests/surrealdb_repository_test.py +0 -0
  137. {rococo-1.3.1 → rococo-1.3.3}/tests/test_calculated_properties_fix.py +0 -0
  138. {rococo-1.3.1 → rococo-1.3.3}/tests/test_faxing.py +0 -0
  139. {rococo-1.3.1 → rococo-1.3.3}/tests/test_mailjet_addresses.py +0 -0
  140. {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.1
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.args[0] == 1213:
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
- match = re.match(r'^(.*)\s*<(.*)>$', self.config.SOURCE_EMAIL)
75
- name, email = match.groups()
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, "warning")
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, "warning")
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))