openbox-temporal-sdk-python 1.0.1__tar.gz → 1.0.2__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 (39) hide show
  1. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/.github/workflows/publish.yml +2 -3
  2. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/.github/workflows/sonarqube.yaml +1 -1
  3. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/PKG-INFO +20 -4
  4. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/README.md +19 -3
  5. openbox_temporal_sdk_python-1.0.2/docs/configuration.md +63 -0
  6. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/otel_setup.py +42 -4
  7. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/worker.py +6 -0
  8. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/pyproject.toml +1 -1
  9. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_otel_setup.py +120 -3
  10. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_worker.py +73 -0
  11. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/.gitignore +0 -0
  12. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/.python-version +0 -0
  13. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/.repomixignore +0 -0
  14. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/LICENSE +0 -0
  15. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/docs/code-standards.md +0 -0
  16. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/docs/codebase-summary.md +0 -0
  17. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/docs/project-overview-pdr.md +0 -0
  18. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/docs/system-architecture.md +0 -0
  19. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/__init__.py +0 -0
  20. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/activities.py +0 -0
  21. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/activity_interceptor.py +0 -0
  22. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/config.py +0 -0
  23. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/py.typed +0 -0
  24. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/span_processor.py +0 -0
  25. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/tracing.py +0 -0
  26. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/types.py +0 -0
  27. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/openbox/workflow_interceptor.py +0 -0
  28. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/release-manifest.json +0 -0
  29. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/repomix-output.xml +0 -0
  30. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/sonar-project.properties +0 -0
  31. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/__init__.py +0 -0
  32. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_activities.py +0 -0
  33. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_activity_interceptor.py +0 -0
  34. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_config.py +0 -0
  35. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_span_processor.py +0 -0
  36. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_tracing.py +0 -0
  37. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_types.py +0 -0
  38. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/tests/test_workflow_interceptor.py +0 -0
  39. {openbox_temporal_sdk_python-1.0.1 → openbox_temporal_sdk_python-1.0.2}/uv.lock +0 -0
@@ -1,9 +1,8 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- push:
5
- tags:
6
- - "v*"
4
+ release:
5
+ types: [created]
7
6
 
8
7
  jobs:
9
8
  publish:
@@ -117,7 +117,7 @@ jobs:
117
117
  cat qg.md >> "$GITHUB_STEP_SUMMARY"
118
118
  echo "STATUS=${STATUS}" >> "$GITHUB_ENV"
119
119
  echo "QG_NAME=${QG_NAME}" >> "$GITHUB_ENV"
120
- echo "PNAME=${PNAME}" >> "$GITHUB_ENV" # truyền sang bước sau
120
+ echo "PNAME=${PNAME}" >> "$GITHUB_ENV"
121
121
 
122
122
  # (2) Upsert Issue cho
123
123
  - name: Comment (or create) Issue with QG report
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-temporal-sdk-python
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: OpenBox SDK - Governance and observability for Temporal workflows
5
5
  Project-URL: Homepage, https://github.com/OpenBox-AI/temporal-sdk-python
6
6
  Project-URL: Documentation, https://github.com/OpenBox-AI/temporal-sdk-python#readme
@@ -140,7 +140,8 @@ worker = create_openbox_worker(
140
140
 
141
141
  # Database instrumentation
142
142
  instrument_databases=True,
143
- db_libraries={"psycopg2", "redis"}, # None = all available
143
+ db_libraries={"psycopg2", "sqlalchemy"}, # None = all available
144
+ sqlalchemy_engine=engine, # pass pre-existing engine for query capture
144
145
 
145
146
  # File I/O instrumentation
146
147
  instrument_file_io=False, # disabled by default
@@ -238,6 +239,18 @@ Configure error policy via `on_api_error`:
238
239
  - Redis: `redis`
239
240
  - ORM: `sqlalchemy`
240
241
 
242
+ **SQLAlchemy Note:** If your SQLAlchemy engine is created before `create_openbox_worker()` runs (e.g., at module import time), you must pass it via the `sqlalchemy_engine` parameter. Without this, `SQLAlchemyInstrumentor` only patches future `create_engine()` calls and won't capture queries on pre-existing engines.
243
+
244
+ ```python
245
+ from db.engine import engine
246
+
247
+ worker = create_openbox_worker(
248
+ ...,
249
+ db_libraries={"psycopg2", "sqlalchemy"},
250
+ sqlalchemy_engine=engine,
251
+ )
252
+ ```
253
+
241
254
  ### File I/O
242
255
  - `open()`, `read()`, `write()`, `readline()`, `readlines()`
243
256
  - Skips system paths (`/dev/`, `/proc/`, `/sys/`, `__pycache__`)
@@ -284,7 +297,10 @@ span_processor = WorkflowSpanProcessor(
284
297
  )
285
298
 
286
299
  # 3. Setup OTel instrumentation
287
- setup_opentelemetry_for_governance(span_processor)
300
+ setup_opentelemetry_for_governance(
301
+ span_processor,
302
+ sqlalchemy_engine=engine, # optional: instrument pre-existing engine
303
+ )
288
304
 
289
305
  # 4. Create governance config
290
306
  config = GovernanceConfig(
@@ -355,4 +371,4 @@ MIT License - See LICENSE file for details
355
371
 
356
372
  ---
357
373
 
358
- **Version:** 1.0.0 | **Last Updated:** 2026-02-04
374
+ **Version:** 1.0.2 | **Last Updated:** 2026-02-12
@@ -91,7 +91,8 @@ worker = create_openbox_worker(
91
91
 
92
92
  # Database instrumentation
93
93
  instrument_databases=True,
94
- db_libraries={"psycopg2", "redis"}, # None = all available
94
+ db_libraries={"psycopg2", "sqlalchemy"}, # None = all available
95
+ sqlalchemy_engine=engine, # pass pre-existing engine for query capture
95
96
 
96
97
  # File I/O instrumentation
97
98
  instrument_file_io=False, # disabled by default
@@ -189,6 +190,18 @@ Configure error policy via `on_api_error`:
189
190
  - Redis: `redis`
190
191
  - ORM: `sqlalchemy`
191
192
 
193
+ **SQLAlchemy Note:** If your SQLAlchemy engine is created before `create_openbox_worker()` runs (e.g., at module import time), you must pass it via the `sqlalchemy_engine` parameter. Without this, `SQLAlchemyInstrumentor` only patches future `create_engine()` calls and won't capture queries on pre-existing engines.
194
+
195
+ ```python
196
+ from db.engine import engine
197
+
198
+ worker = create_openbox_worker(
199
+ ...,
200
+ db_libraries={"psycopg2", "sqlalchemy"},
201
+ sqlalchemy_engine=engine,
202
+ )
203
+ ```
204
+
192
205
  ### File I/O
193
206
  - `open()`, `read()`, `write()`, `readline()`, `readlines()`
194
207
  - Skips system paths (`/dev/`, `/proc/`, `/sys/`, `__pycache__`)
@@ -235,7 +248,10 @@ span_processor = WorkflowSpanProcessor(
235
248
  )
236
249
 
237
250
  # 3. Setup OTel instrumentation
238
- setup_opentelemetry_for_governance(span_processor)
251
+ setup_opentelemetry_for_governance(
252
+ span_processor,
253
+ sqlalchemy_engine=engine, # optional: instrument pre-existing engine
254
+ )
239
255
 
240
256
  # 4. Create governance config
241
257
  config = GovernanceConfig(
@@ -306,4 +322,4 @@ MIT License - See LICENSE file for details
306
322
 
307
323
  ---
308
324
 
309
- **Version:** 1.0.0 | **Last Updated:** 2026-02-04
325
+ **Version:** 1.0.2 | **Last Updated:** 2026-02-12
@@ -0,0 +1,63 @@
1
+ # OpenBox SDK Configuration
2
+
3
+ ## Required
4
+
5
+ | Parameter | Type | Description |
6
+ |-----------|------|-------------|
7
+ | `openbox_url` | `str` | OpenBox Core API URL (HTTPS required for non-localhost) |
8
+ | `openbox_api_key` | `str` | API key (`obx_live_*` or `obx_test_*`) |
9
+
10
+ ## Governance
11
+
12
+ | Parameter | Type | Default | Description |
13
+ |-----------|------|---------|-------------|
14
+ | `governance_timeout` | `float` | `30.0` | API timeout in seconds |
15
+ | `governance_policy` | `str` | `"fail_open"` | `"fail_open"` or `"fail_closed"` |
16
+
17
+ ## Event Filtering
18
+
19
+ | Parameter | Type | Default | Description |
20
+ |-----------|------|---------|-------------|
21
+ | `send_start_event` | `bool` | `True` | Send WorkflowStarted events |
22
+ | `send_activity_start_event` | `bool` | `True` | Send ActivityStarted events |
23
+ | `skip_workflow_types` | `set` | `None` | Workflow types to skip |
24
+ | `skip_activity_types` | `set` | `None` | Activity types to skip |
25
+ | `skip_signals` | `set` | `None` | Signal names to skip |
26
+
27
+ ## Human-in-the-Loop
28
+
29
+ | Parameter | Type | Default | Description |
30
+ |-----------|------|---------|-------------|
31
+ | `hitl_enabled` | `bool` | `True` | Enable approval polling for `REQUIRE_APPROVAL` |
32
+
33
+ ## Instrumentation
34
+
35
+ | Parameter | Type | Default | Description |
36
+ |-----------|------|---------|-------------|
37
+ | `instrument_databases` | `bool` | `True` | Capture database queries |
38
+ | `db_libraries` | `set` | `None` | `"psycopg2"`, `"asyncpg"`, `"mysql"`, `"pymysql"`, `"pymongo"`, `"redis"`, `"sqlalchemy"` |
39
+ | `instrument_file_io` | `bool` | `False` | Capture file operations |
40
+
41
+ ## Example
42
+
43
+ ```python
44
+ worker = create_openbox_worker(
45
+ client=client,
46
+ task_queue="my-queue",
47
+ workflows=[MyWorkflow],
48
+ activities=[my_activity],
49
+
50
+ # Required
51
+ openbox_url=os.getenv("OPENBOX_URL"),
52
+ openbox_api_key=os.getenv("OPENBOX_API_KEY"),
53
+
54
+ # Optional
55
+ governance_policy="fail_closed",
56
+ governance_timeout=15.0,
57
+ hitl_enabled=True,
58
+ skip_workflow_types={"InternalWorkflow"},
59
+ instrument_databases=True,
60
+ db_libraries={"psycopg2", "redis"},
61
+ instrument_file_io=False,
62
+ )
63
+ ```
@@ -22,7 +22,7 @@ Supported database libraries:
22
22
  - sqlalchemy (ORM)
23
23
  """
24
24
 
25
- from typing import TYPE_CHECKING, Optional, Set, List
25
+ from typing import TYPE_CHECKING, Any, Optional, Set, List
26
26
  import logging
27
27
 
28
28
  if TYPE_CHECKING:
@@ -70,6 +70,7 @@ def setup_opentelemetry_for_governance(
70
70
  instrument_databases: bool = True,
71
71
  db_libraries: Optional[Set[str]] = None,
72
72
  instrument_file_io: bool = False,
73
+ sqlalchemy_engine: Optional[Any] = None,
73
74
  ) -> None:
74
75
  """
75
76
  Setup OpenTelemetry instrumentors with body capture hooks.
@@ -87,6 +88,10 @@ def setup_opentelemetry_for_governance(
87
88
  Valid values: "psycopg2", "asyncpg", "mysql", "pymysql",
88
89
  "pymongo", "redis", "sqlalchemy"
89
90
  instrument_file_io: Whether to instrument file I/O operations (default: False)
91
+ sqlalchemy_engine: Optional SQLAlchemy Engine instance to instrument. Required
92
+ when the engine is created before instrumentation runs (e.g.,
93
+ at module import time). If not provided, only future engines
94
+ created via create_engine() will be instrumented.
90
95
  """
91
96
  global _span_processor, _ignored_url_prefixes
92
97
  _span_processor = span_processor
@@ -172,8 +177,13 @@ def setup_opentelemetry_for_governance(
172
177
  logger.info(f"OpenTelemetry HTTP instrumentation complete. Instrumented: {instrumented}")
173
178
 
174
179
  # 6. Database instrumentation (optional)
180
+ if sqlalchemy_engine is not None and not instrument_databases:
181
+ logger.warning(
182
+ "sqlalchemy_engine was provided but instrument_databases=False; "
183
+ "engine will not be instrumented"
184
+ )
175
185
  if instrument_databases:
176
- db_instrumented = setup_database_instrumentation(db_libraries)
186
+ db_instrumented = setup_database_instrumentation(db_libraries, sqlalchemy_engine)
177
187
  if db_instrumented:
178
188
  instrumented.extend(db_instrumented)
179
189
 
@@ -334,6 +344,7 @@ def uninstrument_file_io() -> None:
334
344
 
335
345
  def setup_database_instrumentation(
336
346
  db_libraries: Optional[Set[str]] = None,
347
+ sqlalchemy_engine: Optional[Any] = None,
337
348
  ) -> List[str]:
338
349
  """
339
350
  Setup OpenTelemetry database instrumentors.
@@ -351,6 +362,10 @@ def setup_database_instrumentation(
351
362
  - "pymongo" (MongoDB)
352
363
  - "redis"
353
364
  - "sqlalchemy" (ORM)
365
+ sqlalchemy_engine: Optional SQLAlchemy Engine instance to instrument. When
366
+ provided, registers event listeners on this engine to capture
367
+ queries. Without this, only engines created after this call
368
+ (via patched create_engine) will be instrumented.
354
369
 
355
370
  Returns:
356
371
  List of successfully instrumented library names
@@ -424,13 +439,36 @@ def setup_database_instrumentation(
424
439
  logger.debug("redis instrumentation not available")
425
440
 
426
441
  # sqlalchemy (ORM)
442
+ if sqlalchemy_engine is not None and db_libraries is not None and "sqlalchemy" not in db_libraries:
443
+ logger.warning(
444
+ "sqlalchemy_engine was provided but 'sqlalchemy' is not in db_libraries; "
445
+ "engine will not be instrumented"
446
+ )
427
447
  if db_libraries is None or "sqlalchemy" in db_libraries:
428
448
  try:
429
449
  from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
430
450
 
431
- SQLAlchemyInstrumentor().instrument()
451
+ if sqlalchemy_engine is not None:
452
+ # Validate engine type before passing to instrumentor
453
+ try:
454
+ from sqlalchemy.engine import Engine as _SAEngine
455
+ except ImportError:
456
+ raise TypeError(
457
+ "sqlalchemy_engine was provided but sqlalchemy is not installed"
458
+ )
459
+ if not isinstance(sqlalchemy_engine, _SAEngine):
460
+ raise TypeError(
461
+ f"sqlalchemy_engine must be a sqlalchemy.engine.Engine instance, "
462
+ f"got {type(sqlalchemy_engine).__name__}"
463
+ )
464
+ # Instrument the existing engine directly (registers event listeners)
465
+ SQLAlchemyInstrumentor().instrument(engine=sqlalchemy_engine)
466
+ logger.info("Instrumented: sqlalchemy (existing engine)")
467
+ else:
468
+ # Patch create_engine() for future engines only
469
+ SQLAlchemyInstrumentor().instrument()
470
+ logger.info("Instrumented: sqlalchemy (future engines)")
432
471
  instrumented.append("sqlalchemy")
433
- logger.info("Instrumented: sqlalchemy")
434
472
  except ImportError:
435
473
  logger.debug("sqlalchemy instrumentation not available")
436
474
 
@@ -57,6 +57,7 @@ def create_openbox_worker(
57
57
  # Database instrumentation
58
58
  instrument_databases: bool = True,
59
59
  db_libraries: Optional[set] = None,
60
+ sqlalchemy_engine: Optional[Any] = None,
60
61
  # File I/O instrumentation
61
62
  instrument_file_io: bool = False,
62
63
  # Standard Worker options
@@ -117,6 +118,10 @@ def create_openbox_worker(
117
118
  db_libraries: Set of database libraries to instrument (None = all available).
118
119
  Valid values: "psycopg2", "asyncpg", "mysql", "pymysql",
119
120
  "pymongo", "redis", "sqlalchemy"
121
+ sqlalchemy_engine: SQLAlchemy Engine instance to instrument. Pass this when
122
+ the engine is created before create_openbox_worker() runs
123
+ (e.g., at module import time). This ensures query-level
124
+ instrumentation works on pre-existing engines.
120
125
 
121
126
  # File I/O instrumentation
122
127
  instrument_file_io: Instrument file I/O operations (default: False)
@@ -171,6 +176,7 @@ def create_openbox_worker(
171
176
  instrument_databases=instrument_databases,
172
177
  db_libraries=db_libraries,
173
178
  instrument_file_io=instrument_file_io,
179
+ sqlalchemy_engine=sqlalchemy_engine,
174
180
  )
175
181
 
176
182
  # 4. Create governance config
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openbox-temporal-sdk-python"
3
- version = "1.0.1"
3
+ version = "1.0.2"
4
4
  description = "OpenBox SDK - Governance and observability for Temporal workflows"
5
5
  authors = [
6
6
  { name = "OpenBox Team", email = "tino@openbox.ai" },
@@ -462,7 +462,7 @@ class TestSetupOpentelemetryForGovernance:
462
462
  instrument_file_io=False,
463
463
  )
464
464
 
465
- mock_db_setup.assert_called_once_with({"psycopg2"})
465
+ mock_db_setup.assert_called_once_with({"psycopg2"}, None)
466
466
 
467
467
  def test_calls_setup_file_io_instrumentation_when_enabled(self, mock_span_processor):
468
468
  """Should call setup_file_io_instrumentation when instrument_file_io=True."""
@@ -868,7 +868,7 @@ class TestSetupDatabaseInstrumentation:
868
868
  assert "redis" in result
869
869
 
870
870
  def test_instruments_sqlalchemy(self):
871
- """Should instrument sqlalchemy library."""
871
+ """Should instrument sqlalchemy library (future engines path)."""
872
872
  from openbox.otel_setup import setup_database_instrumentation
873
873
 
874
874
  with patch('opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor') as mock_sqlalchemy:
@@ -877,9 +877,126 @@ class TestSetupDatabaseInstrumentation:
877
877
 
878
878
  result = setup_database_instrumentation(db_libraries={"sqlalchemy"})
879
879
 
880
- mock_instance.instrument.assert_called_once()
880
+ mock_instance.instrument.assert_called_once_with()
881
881
  assert "sqlalchemy" in result
882
882
 
883
+ def test_instruments_sqlalchemy_with_existing_engine(self):
884
+ """Should instrument sqlalchemy with existing engine when provided."""
885
+ from openbox.otel_setup import setup_database_instrumentation
886
+
887
+ with patch('opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor') as mock_sqlalchemy:
888
+ mock_instance = MagicMock()
889
+ mock_sqlalchemy.return_value = mock_instance
890
+
891
+ # Create a mock that passes the isinstance check
892
+ with patch('openbox.otel_setup.setup_database_instrumentation') as _:
893
+ pass # just to show we need a real-ish engine
894
+
895
+ # Use a mock Engine that passes isinstance check
896
+ from unittest.mock import create_autospec
897
+ try:
898
+ from sqlalchemy.engine import Engine
899
+ mock_engine = create_autospec(Engine, instance=True)
900
+ except ImportError:
901
+ pytest.skip("sqlalchemy not installed")
902
+
903
+ result = setup_database_instrumentation(
904
+ db_libraries={"sqlalchemy"},
905
+ sqlalchemy_engine=mock_engine,
906
+ )
907
+
908
+ mock_instance.instrument.assert_called_once_with(engine=mock_engine)
909
+ assert "sqlalchemy" in result
910
+
911
+ def test_sqlalchemy_engine_rejects_non_engine_type(self):
912
+ """Should raise TypeError when sqlalchemy_engine is not an Engine instance."""
913
+ from openbox.otel_setup import setup_database_instrumentation
914
+
915
+ try:
916
+ import sqlalchemy # noqa
917
+ except ImportError:
918
+ pytest.skip("sqlalchemy not installed")
919
+
920
+ with patch('opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor'):
921
+ with pytest.raises(TypeError, match="must be a sqlalchemy.engine.Engine instance"):
922
+ setup_database_instrumentation(
923
+ db_libraries={"sqlalchemy"},
924
+ sqlalchemy_engine="not-an-engine",
925
+ )
926
+
927
+ def test_sqlalchemy_engine_rejects_when_sqlalchemy_not_installed(self):
928
+ """Should raise TypeError when engine provided but sqlalchemy not installed."""
929
+ from openbox.otel_setup import setup_database_instrumentation
930
+
931
+ with patch('opentelemetry.instrumentation.sqlalchemy.SQLAlchemyInstrumentor'):
932
+ with patch.dict('sys.modules', {'sqlalchemy': None, 'sqlalchemy.engine': None}):
933
+ with pytest.raises(TypeError, match="sqlalchemy is not installed"):
934
+ setup_database_instrumentation(
935
+ db_libraries={"sqlalchemy"},
936
+ sqlalchemy_engine=MagicMock(),
937
+ )
938
+
939
+ def test_warns_when_engine_provided_but_sqlalchemy_not_in_db_libraries(self):
940
+ """Should warn when sqlalchemy_engine provided but 'sqlalchemy' not in db_libraries."""
941
+ from openbox.otel_setup import setup_database_instrumentation
942
+ import logging
943
+
944
+ with patch('opentelemetry.instrumentation.psycopg2.Psycopg2Instrumentor') as mock_psycopg2:
945
+ mock_psycopg2.return_value = MagicMock()
946
+
947
+ with patch.object(logging.getLogger('openbox.otel_setup'), 'warning') as mock_warn:
948
+ setup_database_instrumentation(
949
+ db_libraries={"psycopg2"},
950
+ sqlalchemy_engine=MagicMock(),
951
+ )
952
+
953
+ mock_warn.assert_called_once()
954
+ assert "not in db_libraries" in mock_warn.call_args[0][0]
955
+
956
+ def test_warns_when_engine_provided_but_databases_disabled(self, mock_span_processor):
957
+ """Should warn when sqlalchemy_engine provided but instrument_databases=False."""
958
+ import openbox.otel_setup as otel_setup
959
+ import logging
960
+
961
+ with patch.object(otel_setup, 'setup_httpx_body_capture'):
962
+ with patch('opentelemetry.trace.get_tracer_provider') as mock_get_provider:
963
+ mock_provider = MagicMock()
964
+ mock_get_provider.return_value = mock_provider
965
+
966
+ with patch.object(logging.getLogger('openbox.otel_setup'), 'warning') as mock_warn:
967
+ otel_setup.setup_opentelemetry_for_governance(
968
+ span_processor=mock_span_processor,
969
+ instrument_databases=False,
970
+ instrument_file_io=False,
971
+ sqlalchemy_engine=MagicMock(),
972
+ )
973
+
974
+ mock_warn.assert_called_once()
975
+ assert "instrument_databases=False" in mock_warn.call_args[0][0]
976
+
977
+ def test_sqlalchemy_engine_passthrough_from_setup_governance(self, mock_span_processor):
978
+ """Should pass sqlalchemy_engine through to setup_database_instrumentation."""
979
+ import openbox.otel_setup as otel_setup
980
+
981
+ mock_engine = MagicMock()
982
+
983
+ with patch.object(otel_setup, 'setup_httpx_body_capture'):
984
+ with patch.object(otel_setup, 'setup_database_instrumentation') as mock_db_setup:
985
+ mock_db_setup.return_value = ["sqlalchemy"]
986
+ with patch('opentelemetry.trace.get_tracer_provider') as mock_get_provider:
987
+ mock_provider = MagicMock()
988
+ mock_get_provider.return_value = mock_provider
989
+
990
+ otel_setup.setup_opentelemetry_for_governance(
991
+ span_processor=mock_span_processor,
992
+ instrument_databases=True,
993
+ db_libraries={"sqlalchemy"},
994
+ instrument_file_io=False,
995
+ sqlalchemy_engine=mock_engine,
996
+ )
997
+
998
+ mock_db_setup.assert_called_once_with({"sqlalchemy"}, mock_engine)
999
+
883
1000
 
884
1001
  # ═══════════════════════════════════════════════════════════════════════════════
885
1002
  # Tests for uninstrument_all()
@@ -135,6 +135,7 @@ class TestCreateOpenboxWorkerWithConfig:
135
135
  instrument_databases=True,
136
136
  db_libraries={"psycopg2", "redis"},
137
137
  instrument_file_io=True,
138
+ sqlalchemy_engine=None,
138
139
  )
139
140
 
140
141
  @patch("openbox.worker.Worker")
@@ -1380,6 +1381,78 @@ class TestConfigurationOptions:
1380
1381
  call_kwargs = mock_setup_otel.call_args[1]
1381
1382
  assert call_kwargs["instrument_file_io"] is True
1382
1383
 
1384
+ @patch("openbox.worker.Worker")
1385
+ @patch("openbox.worker.validate_api_key")
1386
+ @patch("openbox.worker.WorkflowSpanProcessor")
1387
+ @patch("openbox.worker.GovernanceConfig")
1388
+ @patch("openbox.otel_setup.setup_opentelemetry_for_governance")
1389
+ @patch("openbox.workflow_interceptor.GovernanceInterceptor")
1390
+ @patch("openbox.activity_interceptor.ActivityGovernanceInterceptor")
1391
+ @patch("openbox.activities.send_governance_event")
1392
+ def test_sqlalchemy_engine_passed_to_setup(
1393
+ self,
1394
+ mock_send_governance_event,
1395
+ mock_activity_interceptor,
1396
+ mock_governance_interceptor,
1397
+ mock_setup_otel,
1398
+ mock_governance_config,
1399
+ mock_span_processor_class,
1400
+ mock_validate_api_key,
1401
+ mock_worker_class,
1402
+ ):
1403
+ """Test sqlalchemy_engine is passed to setup_opentelemetry_for_governance."""
1404
+ from openbox.worker import create_openbox_worker
1405
+
1406
+ mock_client = Mock()
1407
+ mock_engine = Mock()
1408
+
1409
+ create_openbox_worker(
1410
+ client=mock_client,
1411
+ task_queue="test-queue",
1412
+ openbox_url="http://localhost:8086",
1413
+ openbox_api_key="obx_test_key123",
1414
+ sqlalchemy_engine=mock_engine,
1415
+ )
1416
+
1417
+ mock_setup_otel.assert_called_once()
1418
+ call_kwargs = mock_setup_otel.call_args[1]
1419
+ assert call_kwargs["sqlalchemy_engine"] is mock_engine
1420
+
1421
+ @patch("openbox.worker.Worker")
1422
+ @patch("openbox.worker.validate_api_key")
1423
+ @patch("openbox.worker.WorkflowSpanProcessor")
1424
+ @patch("openbox.worker.GovernanceConfig")
1425
+ @patch("openbox.otel_setup.setup_opentelemetry_for_governance")
1426
+ @patch("openbox.workflow_interceptor.GovernanceInterceptor")
1427
+ @patch("openbox.activity_interceptor.ActivityGovernanceInterceptor")
1428
+ @patch("openbox.activities.send_governance_event")
1429
+ def test_sqlalchemy_engine_defaults_to_none(
1430
+ self,
1431
+ mock_send_governance_event,
1432
+ mock_activity_interceptor,
1433
+ mock_governance_interceptor,
1434
+ mock_setup_otel,
1435
+ mock_governance_config,
1436
+ mock_span_processor_class,
1437
+ mock_validate_api_key,
1438
+ mock_worker_class,
1439
+ ):
1440
+ """Test sqlalchemy_engine defaults to None when not provided."""
1441
+ from openbox.worker import create_openbox_worker
1442
+
1443
+ mock_client = Mock()
1444
+
1445
+ create_openbox_worker(
1446
+ client=mock_client,
1447
+ task_queue="test-queue",
1448
+ openbox_url="http://localhost:8086",
1449
+ openbox_api_key="obx_test_key123",
1450
+ )
1451
+
1452
+ mock_setup_otel.assert_called_once()
1453
+ call_kwargs = mock_setup_otel.call_args[1]
1454
+ assert call_kwargs["sqlalchemy_engine"] is None
1455
+
1383
1456
 
1384
1457
  # ===============================================================================
1385
1458
  # Print Output Tests