tilebox-workflows 0.63.2__tar.gz → 0.64.0__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 (90) hide show
  1. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/PKG-INFO +6 -6
  2. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/pyproject.toml +5 -5
  3. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/__init__.py +12 -0
  4. tilebox_workflows-0.64.0/tilebox/workflows/observability/_log_stream.py +172 -0
  5. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/logging.py +76 -50
  6. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/tracing.py +8 -13
  7. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/__main__.py +10 -5
  8. tilebox_workflows-0.64.0/tilebox/workflows/runner/worker_server.py +128 -0
  9. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/worker_service.py +73 -7
  10. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_connect.py +66 -0
  11. tilebox_workflows-0.64.0/tilebox/workflows/workflows/v1/worker_pb2.py +51 -0
  12. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_pb2.pyi +1 -0
  13. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_pb2_grpc.py +57 -1
  14. tilebox_workflows-0.63.2/tilebox/workflows/observability/_log_pipe.py +0 -124
  15. tilebox_workflows-0.63.2/tilebox/workflows/runner/worker_server.py +0 -67
  16. tilebox_workflows-0.63.2/tilebox/workflows/workflows/v1/worker_pb2.py +0 -50
  17. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/.gitignore +0 -0
  18. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/README.md +0 -0
  19. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codec.py +0 -0
  20. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/__init__.py +0 -0
  21. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/affine.py +0 -0
  22. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/async_geotiff.py +0 -0
  23. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/odc.py +0 -0
  24. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/pyproj.py +0 -0
  25. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/rasterio.py +0 -0
  26. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/shapely.py +0 -0
  27. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/tilebox.py +0 -0
  28. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_serialization.py +0 -0
  29. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/__init__.py +0 -0
  30. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/client.py +0 -0
  31. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/cron.py +0 -0
  32. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/service.py +0 -0
  33. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/storage_event.py +0 -0
  34. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/cache.py +0 -0
  35. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/client.py +0 -0
  36. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/__init__.py +0 -0
  37. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/client.py +0 -0
  38. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/service.py +0 -0
  39. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/data.py +0 -0
  40. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/formatting/__init__.py +0 -0
  41. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/formatting/job.py +0 -0
  42. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/__init__.py +0 -0
  43. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/client.py +0 -0
  44. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/service.py +0 -0
  45. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/telemetry_service.py +0 -0
  46. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/__init__.py +0 -0
  47. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/_logging.py +0 -0
  48. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/__init__.py +0 -0
  49. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/executor.py +0 -0
  50. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/runner.py +0 -0
  51. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/task_runner.py +0 -0
  52. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/task_service.py +0 -0
  53. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/task.py +0 -0
  54. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/__init__.py +0 -0
  55. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/client.py +0 -0
  56. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/service.py +0 -0
  57. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_connect.py +0 -0
  58. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2.py +0 -0
  59. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2.pyi +0 -0
  60. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2_grpc.py +0 -0
  61. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2.py +0 -0
  62. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2.pyi +0 -0
  63. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2_grpc.py +0 -0
  64. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_connect.py +0 -0
  65. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2.py +0 -0
  66. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2.pyi +0 -0
  67. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2_grpc.py +0 -0
  68. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_connect.py +0 -0
  69. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2.py +0 -0
  70. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2.pyi +0 -0
  71. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2_grpc.py +0 -0
  72. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_connect.py +0 -0
  73. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2.py +0 -0
  74. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2.pyi +0 -0
  75. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2_grpc.py +0 -0
  76. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_connect.py +0 -0
  77. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2.py +0 -0
  78. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2.pyi +0 -0
  79. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2_grpc.py +0 -0
  80. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2.py +0 -0
  81. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2.pyi +0 -0
  82. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2_grpc.py +0 -0
  83. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_connect.py +0 -0
  84. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2.py +0 -0
  85. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2.pyi +0 -0
  86. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2_grpc.py +0 -0
  87. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_connect.py +0 -0
  88. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2.py +0 -0
  89. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2.pyi +0 -0
  90. {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2_grpc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tilebox-workflows
3
- Version: 0.63.2
3
+ Version: 0.64.0
4
4
  Summary: Workflow client and task runner for Tilebox
5
5
  Project-URL: Homepage, https://tilebox.com
6
6
  Project-URL: Documentation, https://docs.tilebox.com/workflows/introduction
@@ -25,11 +25,11 @@ Requires-Dist: google-auth[requests]>=2.29
25
25
  Requires-Dist: grpcio>=1.84.0
26
26
  Requires-Dist: msgspec>=0.19
27
27
  Requires-Dist: obstore>=0.8.2
28
- Requires-Dist: opentelemetry-api>=1.43.0
29
- Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.43.0
30
- Requires-Dist: opentelemetry-instrumentation-logging>=0.64b0
31
- Requires-Dist: opentelemetry-proto>=1.43.0
32
- Requires-Dist: opentelemetry-sdk>=1.43.0
28
+ Requires-Dist: opentelemetry-api>=1.44.0
29
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44.0
30
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.65b0
31
+ Requires-Dist: opentelemetry-proto>=1.44.0
32
+ Requires-Dist: opentelemetry-sdk>=1.44.0
33
33
  Requires-Dist: tenacity>=8
34
34
  Requires-Dist: tilebox-datasets>=0.56.0
35
35
  Requires-Dist: tilebox-grpc>=0.28.0
@@ -27,14 +27,14 @@ dependencies = [
27
27
  "grpcio>=1.84.0",
28
28
  "google-auth[requests]>=2.29",
29
29
  "azure-identity>=1.23",
30
- "opentelemetry-api>=1.43.0",
31
- "opentelemetry-exporter-otlp-proto-http>=1.43.0",
32
- "opentelemetry-sdk>=1.43.0",
30
+ "opentelemetry-api>=1.44.0",
31
+ "opentelemetry-exporter-otlp-proto-http>=1.44.0",
32
+ "opentelemetry-sdk>=1.44.0",
33
33
  "tenacity>=8",
34
34
  "boto3>=1.40.2",
35
35
  "obstore>=0.8.2",
36
- "opentelemetry-proto>=1.43.0",
37
- "opentelemetry-instrumentation-logging>=0.64b0",
36
+ "opentelemetry-proto>=1.44.0",
37
+ "opentelemetry-instrumentation-logging>=0.65b0",
38
38
  "msgspec>=0.19",
39
39
  ]
40
40
 
@@ -1,6 +1,9 @@
1
1
  import os
2
2
  from typing import TYPE_CHECKING, Any
3
3
 
4
+ # Apply native logging defaults before any submodule can initialize gRPC.
5
+ import _tilebox.grpc # noqa: F401
6
+
4
7
  if TYPE_CHECKING:
5
8
  from tilebox.workflows.client import Client
6
9
  from tilebox.workflows.data import Job
@@ -48,8 +51,17 @@ def __dir__() -> list[str]:
48
51
  def _initialize_logging_from_environment() -> None:
49
52
  # Stage 1: current CLIs provide credentials before importing workflow code, so
50
53
  # even import-time logs reach the API. Ordinary SDK imports stay lightweight.
54
+ runtime_dir = os.environ.get("TILEBOX_RUNTIME_DIR")
55
+ runtime_token = os.environ.get("TILEBOX_RUNTIME_TOKEN")
56
+ if bool(runtime_dir) != bool(runtime_token):
57
+ raise RuntimeError("TILEBOX_RUNTIME_DIR and TILEBOX_RUNTIME_TOKEN must be set together")
58
+
51
59
  url = os.environ.get("TILEBOX_API_URL")
52
60
  token = os.environ.get("TILEBOX_API_KEY")
61
+ if runtime_dir and runtime_token:
62
+ # Importing installs the managed local stream before workflow modules can log.
63
+ from tilebox.workflows.observability import logging as _runtime_logging # noqa: F401, PLC0415
64
+
53
65
  if not url or not token:
54
66
  return
55
67
 
@@ -0,0 +1,172 @@
1
+ """Bounded OTLP log streaming for CLI-managed workflow runtimes."""
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ import traceback
8
+ from collections import deque
9
+ from collections.abc import Callable, Iterator
10
+ from typing import Any
11
+
12
+ from opentelemetry.instrumentation.log_utils import std_to_otel
13
+ from opentelemetry.proto.common.v1.common_pb2 import AnyValue, ArrayValue, KeyValue, KeyValueList
14
+ from opentelemetry.proto.logs.v1.logs_pb2 import LogRecord, SeverityNumber
15
+ from opentelemetry.semconv.attributes import exception_attributes
16
+
17
+ from tilebox.workflows.observability._logging import _record_attributes
18
+
19
+ _MAX_RECORD_SIZE = 1024 * 1024
20
+ _MANAGED_RUNTIME = bool(os.environ.get("TILEBOX_RUNTIME_DIR") and os.environ.get("TILEBOX_RUNTIME_TOKEN"))
21
+
22
+
23
+ def _any_value(value: Any) -> AnyValue: # noqa: PLR0911 -- each protobuf union arm is clearest explicitly
24
+ """Convert an already-normalized JSON value to OTLP."""
25
+ if value is None:
26
+ return AnyValue()
27
+ if isinstance(value, bool):
28
+ return AnyValue(bool_value=value)
29
+ if isinstance(value, int):
30
+ if abs(value) > 2**63 - 1:
31
+ return AnyValue(string_value=str(value))
32
+ return AnyValue(int_value=value)
33
+ if isinstance(value, float):
34
+ return AnyValue(double_value=value)
35
+ if isinstance(value, dict):
36
+ return AnyValue(
37
+ kvlist_value=KeyValueList(values=[KeyValue(key=key, value=_any_value(item)) for key, item in value.items()])
38
+ )
39
+ if isinstance(value, list):
40
+ return AnyValue(array_value=ArrayValue(values=[_any_value(item) for item in value]))
41
+ return AnyValue(string_value=value)
42
+
43
+
44
+ class _LogQueue:
45
+ """Non-blocking bounded queue with explicit sealed-and-empty termination."""
46
+
47
+ def __init__(self, capacity: int = 256, max_record_size: int = _MAX_RECORD_SIZE) -> None:
48
+ self._capacity = capacity
49
+ self._max_record_size = max_record_size
50
+ self._records: deque[LogRecord] = deque()
51
+ self._condition = threading.Condition()
52
+ self._subscriber = False
53
+ self._sealed = False
54
+ self._dropped = 0
55
+
56
+ def submit(self, record: LogRecord) -> None:
57
+ with self._condition:
58
+ if self._sealed:
59
+ return
60
+ if record.ByteSize() > self._max_record_size or len(self._records) >= self._capacity:
61
+ self._dropped += 1
62
+ self._condition.notify_all()
63
+ return
64
+ if self._dropped:
65
+ self._records.append(_drop_record(self._dropped))
66
+ self._dropped = 0
67
+ if len(self._records) >= self._capacity:
68
+ self._dropped += 1
69
+ self._condition.notify_all()
70
+ return
71
+ self._records.append(record)
72
+ self._condition.notify_all()
73
+
74
+ def subscribe(self, active: Callable[[], bool] = lambda: True) -> Iterator[LogRecord]:
75
+ with self._condition:
76
+ if self._subscriber:
77
+ raise RuntimeError("A log subscriber is already connected")
78
+ self._subscriber = True
79
+ try:
80
+ while active():
81
+ with self._condition:
82
+ if self._records:
83
+ record = self._records.popleft()
84
+ self._condition.notify_all()
85
+ elif self._dropped:
86
+ record = _drop_record(self._dropped)
87
+ self._dropped = 0
88
+ self._condition.notify_all()
89
+ elif self._sealed:
90
+ return
91
+ else:
92
+ self._condition.wait(timeout=0.1)
93
+ continue
94
+ yield record
95
+ finally:
96
+ with self._condition:
97
+ self._subscriber = False
98
+ self._condition.notify_all()
99
+
100
+ def seal(self) -> None:
101
+ with self._condition:
102
+ self._sealed = True
103
+ self._condition.notify_all()
104
+
105
+ def wait_empty(self, timeout: float) -> bool:
106
+ deadline = time.monotonic() + timeout
107
+ with self._condition:
108
+ while self._records or self._dropped:
109
+ remaining = deadline - time.monotonic()
110
+ if remaining <= 0:
111
+ return False
112
+ self._condition.wait(timeout=remaining)
113
+ return True
114
+
115
+
116
+ def _drop_record(count: int) -> LogRecord:
117
+ return LogRecord(
118
+ time_unix_nano=time.time_ns(),
119
+ severity_number=_severity_number(logging.WARNING),
120
+ severity_text="WARN",
121
+ body=AnyValue(string_value=f"Dropped {count} local log records"),
122
+ )
123
+
124
+
125
+ class _OTLPQueueHandler(logging.Handler):
126
+ def __init__(self, records: _LogQueue) -> None:
127
+ super().__init__(logging.NOTSET)
128
+ self._records = records
129
+
130
+ def emit(self, record: logging.LogRecord) -> None:
131
+ try:
132
+ attributes = dict(_record_attributes(record))
133
+ trace_id = _hex_id(attributes.pop("trace_id", ""), 16)
134
+ span_id = _hex_id(attributes.pop("span_id", ""), 8)
135
+ if record.exc_info:
136
+ exctype, value, tb = record.exc_info
137
+ if exctype is not None:
138
+ attributes[exception_attributes.EXCEPTION_TYPE] = exctype.__name__
139
+ if value is not None:
140
+ attributes[exception_attributes.EXCEPTION_MESSAGE] = str(value)
141
+ if tb is not None:
142
+ attributes[exception_attributes.EXCEPTION_STACKTRACE] = "".join(
143
+ traceback.format_exception(*record.exc_info)
144
+ )
145
+ self._records.submit(
146
+ LogRecord(
147
+ time_unix_nano=int(record.created * 1e9),
148
+ severity_number=_severity_number(record.levelno),
149
+ severity_text={"WARNING": "WARN", "CRITICAL": "FATAL"}.get(record.levelname, record.levelname),
150
+ body=_any_value(record.getMessage()),
151
+ attributes=[KeyValue(key=str(key), value=_any_value(value)) for key, value in attributes.items()],
152
+ trace_id=trace_id,
153
+ span_id=span_id,
154
+ )
155
+ )
156
+ except Exception: # noqa: BLE001 -- logging must never break workflow execution
157
+ self.handleError(record)
158
+
159
+
160
+ def _hex_id(value: Any, size: int) -> bytes:
161
+ try:
162
+ result = bytes.fromhex(value) if isinstance(value, str) else b""
163
+ except ValueError:
164
+ return b""
165
+ return result if len(result) == size else b""
166
+
167
+
168
+ def _severity_number(level: int) -> SeverityNumber.ValueType:
169
+ return SeverityNumber.Value(f"SEVERITY_NUMBER_{std_to_otel(level).name}")
170
+
171
+
172
+ managed_log_queue = _LogQueue() if _MANAGED_RUNTIME else None
@@ -1,5 +1,4 @@
1
1
  # allow the logging module name which shadows the builtin:
2
- import atexit
3
2
  import contextlib
4
3
  import logging
5
4
  import os
@@ -8,18 +7,20 @@ import re
8
7
  import sys
9
8
  import threading
10
9
  import traceback
10
+ from contextvars import ContextVar
11
11
  from datetime import timedelta
12
12
  from importlib.metadata import PackageNotFoundError, version
13
13
  from typing import Any, ClassVar, TextIO
14
14
  from uuid import UUID, uuid4
15
15
 
16
16
  import msgspec
17
+ from opentelemetry._logs import LogRecord, get_logger_provider
18
+ from opentelemetry._logs import get_logger as get_otel_logger
17
19
  from opentelemetry.exporter.otlp.proto.http._log_exporter import (
18
20
  DEFAULT_LOGS_EXPORT_PATH,
19
21
  OTLPLogExporter,
20
- _append_logs_path,
21
22
  )
22
- from opentelemetry.instrumentation.logging.handler import LoggingHandler
23
+ from opentelemetry.instrumentation.log_utils import std_to_otel
23
24
  from opentelemetry.sdk._logs import LoggerProvider
24
25
  from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
25
26
  from opentelemetry.sdk.resources import (
@@ -34,10 +35,9 @@ from opentelemetry.sdk.resources import (
34
35
  Resource,
35
36
  )
36
37
  from opentelemetry.semconv.attributes import exception_attributes
37
- from opentelemetry.util.types import _ExtendedAttributes
38
38
 
39
39
  from tilebox.workflows._serialization import normalize_log_value
40
- from tilebox.workflows.observability._log_pipe import _PipeWriter, _StructuredHandler
40
+ from tilebox.workflows.observability._log_stream import _OTLPQueueHandler, managed_log_queue
41
41
  from tilebox.workflows.observability._logging import (
42
42
  StructuredLogger as StructuredLogger, # noqa: PLC0414 -- public compatibility alias
43
43
  )
@@ -64,13 +64,7 @@ _instance_id = str(UUID(_instance_id))
64
64
 
65
65
 
66
66
  def _get_default_resource(service: str | Resource | None = None) -> Resource:
67
- if isinstance(service, Resource): # already a resource object
68
- service_name = service.attributes.get(SERVICE_NAME)
69
- if service_name is not None and service_name != "unknown_service":
70
- # default value of SERVICE_NAME is "unknown_service", so if we have anything other than that we
71
- # know it's already configured
72
- return service
73
-
67
+ """Build Tilebox defaults, then apply explicitly configured resource attributes."""
74
68
  service_name = service if isinstance(service, str) else _DEFAULT_SERVICE_NAME
75
69
 
76
70
  workflows_version = "dev"
@@ -78,7 +72,7 @@ def _get_default_resource(service: str | Resource | None = None) -> Resource:
78
72
  workflows_version = version("tilebox-workflows")
79
73
 
80
74
  uname = platform.uname()
81
- return Resource.create(
75
+ resource = Resource.create(
82
76
  attributes={
83
77
  SERVICE_NAMESPACE: "tilebox.workflows",
84
78
  SERVICE_NAME: service_name,
@@ -90,6 +84,7 @@ def _get_default_resource(service: str | Resource | None = None) -> Resource:
90
84
  OS_TYPE: uname.system.lower(),
91
85
  }
92
86
  )
87
+ return resource.merge(service) if isinstance(service, Resource) else resource
93
88
 
94
89
 
95
90
  def _sanitize_otel_attribute_value(
@@ -110,15 +105,43 @@ def _sanitize_otel_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
110
105
  return {str(key): _sanitize_otel_attribute_value(value) for key, value in attributes.items()}
111
106
 
112
107
 
113
- class OTELLoggingHandler(LoggingHandler):
114
- def _get_attributes(self, record: logging.LogRecord) -> _ExtendedAttributes:
108
+ class OTELLoggingHandler(logging.Handler):
109
+ def __init__(self, level: int = logging.NOTSET, logger_provider: LoggerProvider | None = None) -> None:
110
+ super().__init__(level)
111
+ self._logger_provider = logger_provider or get_logger_provider()
112
+ self._emitting = ContextVar("tilebox_otel_emitting", default=False)
113
+
114
+ def emit(self, record: logging.LogRecord) -> None:
115
+ # Export processors can log themselves; do not recursively export those records.
116
+ if self._emitting.get():
117
+ return
118
+ token = self._emitting.set(True)
119
+ try:
120
+ get_otel_logger(record.name, logger_provider=self._logger_provider).emit(
121
+ LogRecord(
122
+ timestamp=int(record.created * 1e9),
123
+ severity_number=std_to_otel(record.levelno),
124
+ severity_text={"WARNING": "WARN", "CRITICAL": "FATAL"}.get(record.levelname, record.levelname),
125
+ body=self.format(record) if self.formatter else record.getMessage(),
126
+ attributes=self._get_attributes(record),
127
+ )
128
+ )
129
+ finally:
130
+ self._emitting.reset(token)
131
+
132
+ def flush(self) -> None:
133
+ if callable(force_flush := getattr(self._logger_provider, "force_flush", None)):
134
+ # Match OTEL's handler: flushing under the logging lock can deadlock.
135
+ # Do not prolong process exit after a runtime's bounded final flush.
136
+ threading.Thread(target=force_flush, daemon=True).start()
137
+
138
+ def _get_attributes(self, record: logging.LogRecord) -> dict[str, Any]:
115
139
  cached = getattr(record, "_tilebox_otel_attributes", None)
116
140
  if cached is not None:
117
141
  return cached
118
142
  attributes = _sanitize_otel_attributes(_record_attributes(record))
119
143
 
120
- # the default implementation returns attributes for the filepath, lineno and function of the log record
121
- # we don't want that by default, so we override it to return an empty dict
144
+ # Export only task context and exception details, not stdlib logging metadata.
122
145
  if record.exc_info:
123
146
  exctype, value, tb = record.exc_info
124
147
  if exctype is not None:
@@ -138,10 +161,12 @@ class OTELLoggingHandler(LoggingHandler):
138
161
 
139
162
  _api_handler: OTELLoggingHandler | None = None
140
163
  _initialization_lock = threading.Lock()
141
- _writer: _PipeWriter | None = None
142
164
  _console_handlers: list[logging.Handler] = []
143
165
  _console_configured = False
144
166
 
167
+ if managed_log_queue is not None:
168
+ root_logger.addHandler(_OTLPQueueHandler(managed_log_queue))
169
+
145
170
 
146
171
  def _remove_console_handlers() -> None:
147
172
  for handler in _console_handlers:
@@ -158,27 +183,13 @@ def _add_console_handler(level: int, stream: TextIO, formatter: logging.Formatte
158
183
  _console_handlers.append(handler)
159
184
 
160
185
 
161
- def _configure_runtime_logging() -> _PipeWriter | None:
162
- """Install the required CLI pipe, or an optional default console for direct runners.
163
-
164
- The CLI pipe is runtime-owned: public configuration can add outputs but cannot
165
- disable or replace it. Console opt-out applies only to console handlers.
166
- """
167
- global _writer # noqa: PLW0603 -- process-owned CLI pipe
186
+ def _configure_runtime_logging() -> None:
187
+ """Add an optional default console when logs aren't streamed to a managed CLI."""
168
188
  with _initialization_lock:
169
- fd_value = os.environ.get("TILEBOX_LOG_FD")
170
- if fd_value is not None:
171
- if _writer is None:
172
- _writer = _PipeWriter(int(fd_value))
173
- root_logger.addHandler(_StructuredHandler(_writer))
174
- atexit.register(_writer.close)
175
- if not _console_configured:
176
- _remove_console_handlers()
177
- elif not _console_configured and not _console_handlers:
189
+ if managed_log_queue is None and not _console_configured and not _console_handlers:
178
190
  _add_console_handler(
179
191
  logging.NOTSET, sys.stdout, logging.Formatter("%(process)d: %(levelname)s: %(message)s")
180
192
  )
181
- return _writer
182
193
 
183
194
 
184
195
  def configure_log_level(level: int = logging.INFO, *, tilebox_debug: bool = False) -> None:
@@ -191,7 +202,7 @@ def configure_log_level(level: int = logging.INFO, *, tilebox_debug: bool = Fals
191
202
  filtered out by the logger.
192
203
 
193
204
  Every call overrides both startup settings: omitting tilebox_debug disables internal
194
- DEBUG logging even if TILEBOX_DEBUG was enabled. Existing API exports, the CLI pipe,
205
+ DEBUG logging even if TILEBOX_DEBUG was enabled. Existing API exports, the CLI stream,
195
206
  and console handlers are unchanged and retain their own output thresholds.
196
207
 
197
208
  Args:
@@ -215,7 +226,12 @@ def initialize_logging(url: str, token: str | None, service: str | None = None)
215
226
  if _api_handler is not None:
216
227
  return
217
228
 
218
- provider = LoggerProvider(resource=_get_default_resource(service))
229
+ # CLI runtimes own the final, bounded flush. An additional OTEL atexit
230
+ # shutdown could wait indefinitely on an exporter after that deadline.
231
+ provider = LoggerProvider(
232
+ resource=_get_default_resource(service),
233
+ shutdown_on_exit=not bool(os.environ.get("TILEBOX_WORKER_ADDRESS")),
234
+ )
219
235
  processor = _otel_log_exporter(endpoint=url, headers={"Authorization": f"Bearer {token}"} if token else None)
220
236
  provider.add_log_record_processor(processor)
221
237
  handler = OTELLoggingHandler(level=logging.NOTSET, logger_provider=provider)
@@ -223,6 +239,20 @@ def initialize_logging(url: str, token: str | None, service: str | None = None)
223
239
  _api_handler = handler
224
240
 
225
241
 
242
+ def flush_api_logging(timeout_millis: int = 1000) -> None:
243
+ """Flush the managed API exporter without waiting indefinitely."""
244
+ handler = _api_handler
245
+ if handler is None:
246
+ return
247
+ force_flush = getattr(handler._logger_provider, "force_flush", None) # noqa: SLF001
248
+ if callable(force_flush):
249
+ # BatchLogRecordProcessor currently ignores its timeout argument.
250
+ # Bound our wait without making the export thread block process exit.
251
+ flush = threading.Thread(target=force_flush, kwargs={"timeout_millis": timeout_millis}, daemon=True)
252
+ flush.start()
253
+ flush.join(timeout=timeout_millis / 1000)
254
+
255
+
226
256
  def _otel_log_exporter(
227
257
  endpoint: str | None = None,
228
258
  headers: dict[str, str] | None = None,
@@ -237,7 +267,7 @@ def _otel_log_exporter(
237
267
  )
238
268
 
239
269
  if not endpoint.endswith(DEFAULT_LOGS_EXPORT_PATH):
240
- endpoint = _append_logs_path(endpoint)
270
+ endpoint = f"{endpoint.rstrip('/')}/{DEFAULT_LOGS_EXPORT_PATH}"
241
271
 
242
272
  if export_interval is None:
243
273
  export_interval_env = os.environ.get(_OTEL_EXPORT_INTERVAL_ENV_VAR, None)
@@ -266,14 +296,12 @@ def configure_otel_logging(
266
296
  This will configure a logging handler that will send log messages to an OTLP compatible endpoint using the
267
297
  open telemetry protocol for exporting logs. The logging handler will be attached to the root tilebox logger.
268
298
  All loggers created using `get_logger()` will therefore inherit this handler configuration.
269
- Each call adds an export; Tilebox's API export, the CLI pipe, and console outputs remain installed.
299
+ Each call adds an export; Tilebox's API export, the CLI stream, and console outputs remain installed.
270
300
 
271
301
  Args:
272
- service: A string or a resource object to include in all traces. Used to identify the service being traced.
273
- If a string is provided, it will be used as the service name. If a resource object is provided, it will be
274
- used as the resource. Defaults to a resource with the service name set to "tilebox.workflows-{process_id}",
275
- the version set to the version of the package, and the service instance id set to a combination
276
- of hostname and process id.
302
+ service: A service name or resource whose attributes override the Tilebox defaults.
303
+ Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
304
+ and the CLI runtime ID or a process-local UUID shared by logs and traces.
277
305
  level: The logging level to use for the OTEL handler. Only log messages with a level higher or equal to
278
306
  this will be sent to the endpoint. Defaults to logging.DEBUG. It is typically recommended to keep this at a
279
307
  lower level, since actual filtering of log messages to higher levels is typically done by the logger itself.
@@ -313,11 +341,9 @@ def configure_otel_logging_axiom(
313
341
  configuration.
314
342
 
315
343
  Args:
316
- service: A string or a resource object to include in all traces. Used to identify the service being traced.
317
- If a string is provided, it will be used as the service name. If a resource object is provided, it will be
318
- used as the resource. Defaults to a resource with the service name set to "tilebox.workflows-{process_id}",
319
- the version set to the version of the package, and the service instance id set to a combination
320
- of hostname and process id.
344
+ service: A service name or resource whose attributes override the Tilebox defaults.
345
+ Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
346
+ and the CLI runtime ID or a process-local UUID shared by logs and traces.
321
347
  level: The logging level to use for the Axiom log handler. Only log messages with a level higher or equal to
322
348
  this will be sent to the endpoint. Defaults to logging.DEBUG. It is typically recommended to keep this at a
323
349
  lower level, since actual filtering of log messages to higher levels is typically done by the logger itself.
@@ -397,7 +423,7 @@ def configure_console_logging(
397
423
  configured console logging handlers will be removed. If False, the existing handlers will be kept. Useful
398
424
  if you want to log to multiple consoles.
399
425
  enabled: If False, remove Tilebox-managed console handlers and disable automatic console output, even if
400
- called before runner startup. API exports, the CLI log pipe, and user-installed handlers are unaffected.
426
+ called before runner startup. API exports, the CLI log stream, and user-installed handlers are unaffected.
401
427
  """
402
428
  global _console_configured # noqa: PLW0603 -- explicit process-wide console policy
403
429
  with _initialization_lock:
@@ -8,7 +8,6 @@ from typing import Any, Protocol, cast
8
8
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
9
9
  DEFAULT_TRACES_EXPORT_PATH,
10
10
  OTLPSpanExporter,
11
- _append_trace_path,
12
11
  )
13
12
  from opentelemetry.sdk.resources import Resource
14
13
  from opentelemetry.sdk.trace import TracerProvider
@@ -46,7 +45,7 @@ _OTEL_EXPORT_INTERVAL_ENV_VAR = "OTEL_EXPORT_INTERVAL"
46
45
  # to extract trace_ids and spans, in case other runners / workflow clients have tracing configured.
47
46
  # So instead we use a tracer provider without any exporters, which will still create traces and spans,
48
47
  # but will not send them anywhere.
49
- _tilebox_tracer_provider = TracerProvider()
48
+ _tilebox_tracer_provider = TracerProvider(resource=_get_default_resource())
50
49
  _workflow_tracers = []
51
50
 
52
51
 
@@ -159,7 +158,7 @@ def _otel_span_exporter(
159
158
  )
160
159
 
161
160
  if not endpoint.endswith(DEFAULT_TRACES_EXPORT_PATH):
162
- endpoint = _append_trace_path(endpoint)
161
+ endpoint = f"{endpoint.rstrip('/')}/{DEFAULT_TRACES_EXPORT_PATH}"
163
162
 
164
163
  if export_interval is None:
165
164
  export_interval_env = os.environ.get(_OTEL_EXPORT_INTERVAL_ENV_VAR, None)
@@ -232,11 +231,9 @@ def configure_otel_tracing(
232
231
  Additionally, this will also configure a logging handler that will add log messages to active spans as span events.
233
232
 
234
233
  Args:
235
- service: A string or a resource object to include in all traces. Used to identify the service being traced.
236
- If a string is provided, it will be used as the service name. If a resource object is provided, it will be
237
- used as the resource. Defaults to a resource with the service name set to "tilebox.workflows-{process_id}",
238
- the version set to the version of the package, and the service instance id set to a combination
239
- of hostname and process id.
234
+ service: A service name or resource whose attributes override the Tilebox defaults.
235
+ Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
236
+ and the CLI runtime ID or a process-local UUID shared by logs and traces.
240
237
  endpoint: The URL of the OTLP compatible endpoint to send traces and spans to. If not provided, the environment
241
238
  variable OTEL_TRACES_ENDPOINT will be used. If that is not set either, an error will be raised.
242
239
  OTLP compatible endpoints typically have the path name "/v1/traces". If the specified endpoint does not
@@ -280,11 +277,9 @@ def configure_otel_tracing_axiom(
280
277
  send traces and spans to Axiom.
281
278
 
282
279
  Args:
283
- service: A string or a resource object to include in all traces. Used to identify the service being traced.
284
- If a string is provided, it will be used as the service name. If a resource object is provided, it will be
285
- used as the resource. Defaults to a resource with the service name set to "tilebox.workflows-{process_id}",
286
- the version set to the version of the package, and the service instance id set to a combination
287
- of hostname and process id.
280
+ service: A service name or resource whose attributes override the Tilebox defaults.
281
+ Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
282
+ and the CLI runtime ID or a process-local UUID shared by logs and traces.
288
283
  dataset: The name of the Axiom dataset to ingest traces into. If not provided, the environment variable
289
284
  AXIOM_TRACES_DATASET will be used. If that is not set either, an error will be raised.
290
285
  api_key: The API key to use for authentication. If not provided, the environment variable AXIOM_API_KEY will be
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  from tilebox.workflows.observability._logging import logger
8
8
  from tilebox.workflows.runner.runner import Runner
9
- from tilebox.workflows.runner.worker_server import serve_runner
9
+ from tilebox.workflows.runner.worker_server import WorkerServer
10
10
 
11
11
 
12
12
  def main(argv: Sequence[str] | None = None) -> int:
@@ -17,10 +17,15 @@ def main(argv: Sequence[str] | None = None) -> int:
17
17
  parser.add_argument("runner", help="Runner object import path, for example 'my_workflow.runner:runner'.")
18
18
  args = parser.parse_args(argv)
19
19
 
20
- logger.debug(f"Starting Tilebox workflow runtime for runner {args.runner!r}")
21
- runner = _import_runner(args.runner)
22
- logger.debug(f"Imported runner {args.runner!r}; starting worker server")
23
- serve_runner(runner)
20
+ server = WorkerServer()
21
+ server.start()
22
+ try:
23
+ runner = _import_runner(args.runner)
24
+ server.set_runner(runner)
25
+ server.wait()
26
+ finally:
27
+ server.shutdown()
28
+ server.wait()
24
29
  return 0
25
30
 
26
31