luma-observe-sdk 0.1.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 (32) hide show
  1. luma_observe_sdk-0.1.0/LICENSE +21 -0
  2. luma_observe_sdk-0.1.0/MANIFEST.in +3 -0
  3. luma_observe_sdk-0.1.0/PKG-INFO +328 -0
  4. luma_observe_sdk-0.1.0/README.md +305 -0
  5. luma_observe_sdk-0.1.0/docs/dashboards.md +117 -0
  6. luma_observe_sdk-0.1.0/docs/integration.zh-CN.md +257 -0
  7. luma_observe_sdk-0.1.0/docs/releasing.md +121 -0
  8. luma_observe_sdk-0.1.0/examples/dashboards/custom-metrics.config.json +49 -0
  9. luma_observe_sdk-0.1.0/examples/dashboards/tasks.config.json +124 -0
  10. luma_observe_sdk-0.1.0/pyproject.toml +44 -0
  11. luma_observe_sdk-0.1.0/setup.cfg +4 -0
  12. luma_observe_sdk-0.1.0/src/luma_observe/__init__.py +35 -0
  13. luma_observe_sdk-0.1.0/src/luma_observe/config.py +60 -0
  14. luma_observe_sdk-0.1.0/src/luma_observe/dashboards.py +241 -0
  15. luma_observe_sdk-0.1.0/src/luma_observe/instrumentation/__init__.py +157 -0
  16. luma_observe_sdk-0.1.0/src/luma_observe/instrumentation/celery.py +131 -0
  17. luma_observe_sdk-0.1.0/src/luma_observe/instrumentation/taskiq.py +205 -0
  18. luma_observe_sdk-0.1.0/src/luma_observe/process.py +122 -0
  19. luma_observe_sdk-0.1.0/src/luma_observe/py.typed +0 -0
  20. luma_observe_sdk-0.1.0/src/luma_observe/runtime.py +561 -0
  21. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/PKG-INFO +328 -0
  22. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/SOURCES.txt +30 -0
  23. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/dependency_links.txt +1 -0
  24. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/entry_points.txt +2 -0
  25. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/requires.txt +15 -0
  26. luma_observe_sdk-0.1.0/src/luma_observe_sdk.egg-info/top_level.txt +1 -0
  27. luma_observe_sdk-0.1.0/tests/test_dashboards.py +91 -0
  28. luma_observe_sdk-0.1.0/tests/test_fork.py +279 -0
  29. luma_observe_sdk-0.1.0/tests/test_instrumentation.py +691 -0
  30. luma_observe_sdk-0.1.0/tests/test_propagation.py +259 -0
  31. luma_observe_sdk-0.1.0/tests/test_runtime.py +208 -0
  32. luma_observe_sdk-0.1.0/tests/test_task_runtime.py +365 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luma contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ recursive-include examples *.json
2
+ recursive-include docs *.md
3
+ include README.md
@@ -0,0 +1,328 @@
1
+ Metadata-Version: 2.4
2
+ Name: luma-observe-sdk
3
+ Version: 0.1.0
4
+ Summary: Optional OpenTelemetry application integration and Grafana configuration for Luma.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/LiuTianjie/luma
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: opentelemetry-sdk<2,>=1.38
11
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.38
12
+ Provides-Extra: taskiq
13
+ Requires-Dist: taskiq<0.13,>=0.12.6; extra == "taskiq"
14
+ Provides-Extra: celery
15
+ Requires-Dist: celery<6,>=5.5; extra == "celery"
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest<10,>=8; extra == "test"
18
+ Requires-Dist: pytest-asyncio<2,>=0.24; extra == "test"
19
+ Requires-Dist: build>=1.2; extra == "test"
20
+ Requires-Dist: twine>=6; extra == "test"
21
+ Requires-Dist: ruff>=0.6; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # Luma Observe SDK
25
+
26
+ An optional Python integration package for the shared Luma observability stack.
27
+ It wraps official OpenTelemetry providers and OTLP/HTTP exporters, adds generic
28
+ task lifecycle adapters, and generates standard Grafana dashboard JSON from
29
+ application-owned configuration. It is versioned separately from `luma-infra`
30
+ and imports no Control, Django, business model or database code.
31
+
32
+ Python 3.10 or newer is required. See the [Chinese integration guide](docs/integration.zh-CN.md).
33
+
34
+ ## Install the distribution
35
+
36
+ The package name is `luma-observe-sdk`, initial source version `0.1.0`.
37
+ `Observe SDK Checks` builds wheel/sdist artifacts independently of Control for
38
+ review and validation; expiring CI artifacts are not a permanent dependency source.
39
+ The independent SDK release workflow publishes checked wheel/sdist files and
40
+ `SHA256SUMS` to a permanent GitHub Release under `observe-sdk-v<version>`.
41
+ After publication, use the exact wheel URL with its SHA-256 from the release
42
+ notes, or pin an accessible, immutable Git commit containing the SDK. See the
43
+ [independent SDK release guide](docs/releasing.md) for dependency formats.
44
+ Uncommitted source has no usable remote SHA; placeholders must be replaced
45
+ with the published asset hash or reviewed, pushed commit.
46
+
47
+ To inspect a downloaded wheel, add only the framework extra used by that process:
48
+
49
+ ```sh
50
+ python -m pip install './luma_observe_sdk-0.1.0-py3-none-any.whl[taskiq]'
51
+ # Celery processes use the [celery] extra; processes needing both use [taskiq,celery].
52
+ ```
53
+
54
+ PyPI publication is optional and disabled unless the repository variable
55
+ `OBSERVE_SDK_PYPI_ENABLED` is `true` and its Trusted Publisher is configured.
56
+ Once that exact version is published there, consumers can use
57
+ `luma-observe-sdk[taskiq]==0.1.0` with their normal package configuration.
58
+ The existing `v*` Luma release workflow publishes `luma-infra`, not this package.
59
+
60
+ ## Configure once per process
61
+
62
+ Luma-managed applications keep the existing Control-injected `OTEL_*` endpoint,
63
+ bearer headers and resource attributes. Existing `luma secret` values stay in use.
64
+ For task metrics, set `OTEL_METRICS_EXPORTER=otlp` in the application deployment;
65
+ Control's automatic default remains `none`.
66
+
67
+ ```python
68
+ from luma_observe import configure, span, task
69
+
70
+ configure(
71
+ service_name="document-worker",
72
+ resource_attributes={"deployment.environment.name": "production"},
73
+ )
74
+
75
+ def convert(document):
76
+ with task("document.convert", framework="manual", queue="documents",
77
+ attributes={"document.format": "pdf"}) as run:
78
+ with span("document.render"):
79
+ result = render_document(document)
80
+ if result.needs_review:
81
+ run.outcome = "failed"
82
+ return result
83
+ ```
84
+
85
+ `task` records one attempt, elapsed time and the supplied outcome; it preserves
86
+ the original return and exception. An uncaught exception records its type and a
87
+ failed outcome. Use `succeeded`, `failed`, `retrying`, `skipped` or `cancelled`.
88
+ The application defines business completion separately from framework success.
89
+ Custom attributes belong to spans; task metrics use the fixed dimensions below.
90
+
91
+ Inside an adapter-observed task, use `current_task()` to annotate the current
92
+ attempt without creating another task span:
93
+
94
+ ```python
95
+ from luma_observe import current_task
96
+
97
+ def mark_review_required():
98
+ run = current_task()
99
+ if run is not None:
100
+ run.business_outcome = "requires_review"
101
+ run.business_status = "pending"
102
+ ```
103
+
104
+ The two business fields accept application-defined strings up to 256 characters;
105
+ they appear as `task.business.outcome` and `task.business.status` in spans and
106
+ optional lifecycle logs, never as built-in metric labels. `set_outcome()` accepts
107
+ only the five standard outcomes above. A framework failure or an escaping
108
+ exception takes precedence over a successful business classification.
109
+ `current_task()` is local to the execution context, restores an outer task after a
110
+ nested task ends, and returns `None` outside a live task or in an inherited child
111
+ process until that child starts its own attempt.
112
+
113
+ `get_tracer()` and `get_meter()` return official OTel instruments from the SDK's
114
+ providers for additional application-defined spans and metrics. Keep custom metric
115
+ dimensions bounded; put execution IDs on spans or structured logs. Direct use of
116
+ official instrument APIs follows their normal semantics.
117
+
118
+ The `counter()` and `histogram()` helpers preserve the same metric API while
119
+ isolating instrumentation errors. Applications choose the metric names and fields:
120
+
121
+ ```python
122
+ from luma_observe import counter, histogram
123
+
124
+ completed = counter("document.completed")
125
+ size = histogram("document.pages", unit="1")
126
+ completed.add(1, {"document.format": "pdf", "outcome": "succeeded"})
127
+ size.record(12, {"document.format": "pdf"})
128
+ ```
129
+
130
+ The top-level `counter()` and `histogram()` handles can be declared before
131
+ `configure()` and reused by forked workers; each write uses that process's current
132
+ runtime. Instruments obtained from an explicit `Runtime`, `get_meter()` or
133
+ `get_tracer()` belong to that runtime and process: create them during worker setup.
134
+
135
+ ## Framework integration
136
+
137
+ Taskiq:
138
+
139
+ ```python
140
+ from luma_observe.instrumentation.taskiq import TaskiqMiddleware
141
+
142
+ broker.with_middlewares(TaskiqMiddleware(), existing_retry_middleware)
143
+ ```
144
+
145
+ Place observation first so completion cleanup runs after the existing middleware
146
+ chain. Execution state stays in the callback context, so later middleware can
147
+ copy or rebuild messages. The carrier contains propagation fields and a private,
148
+ serializable invocation ID; live execution objects are not serialized.
149
+ Existing broker transport, retries, result persistence and ACK ownership
150
+ remain with Taskiq. Celery uses its public process/task signals:
151
+
152
+ ```python
153
+ from luma_observe.instrumentation.celery import instrument_celery
154
+
155
+ observation = instrument_celery()
156
+ ```
157
+
158
+ Call this once during Celery app setup; `observation.uninstrument()` disconnects
159
+ the adapter after tasks drain. Providers initialize in the actual process.
160
+ Taskiq's default thread pool carries execution context; application functions
161
+ inside a custom process pool need their own initialization/propagation, while the
162
+ receiver's attempt can still be observed. Both adapters propagate standard W3C
163
+ context and baggage. Every framework execution starts a new trace with a Span Link
164
+ to its producer context, allowing the execution's completion to be sampled
165
+ independently of an earlier producer. Manual `task()` and `span()` retain their
166
+ normal parent semantics; explicit `context` and `links` are also supported.
167
+ The accompanying Collector source separates completed execution roots from early
168
+ child spans for tail sampling. This preserves the completed root summary, not
169
+ every child span in a long trace. Existing Collector deployments need that source
170
+ rebuilt and deployed before this sampling behavior is available; installing the
171
+ SDK alone does not update a running Collector.
172
+ The adapters create short producer preparation spans. Preparation
173
+ spans describe local message preparation, not broker receipt or send success.
174
+ Transport errors and reliable publication state remain the application's concern.
175
+ Use only one task adapter per framework; avoid instrumenting the same task twice
176
+ with another OTel adapter.
177
+
178
+ Adapters observe attempts that reach execution hooks. Revocation before execution
179
+ and hard process termination can leave no completed span. Taskiq's standard
180
+ SmartRetry `NoResultError` conversion identifies a retrying attempt; configurations
181
+ that keep the original error (`no_result_on_retry=False`) expose a failed attempt
182
+ without a definitive retry result. Queue state and business delivery evidence
183
+ remain separate from these attempt metrics.
184
+
185
+ With nested inline Taskiq in-memory execution, a child middleware error caught by
186
+ the parent can leave the child observation context active until the next SDK hook
187
+ restores the parent. Do not use that interim context as proof of parent progress.
188
+ Hard process termination still cannot run completion hooks.
189
+
190
+ ## Disable and bound telemetry
191
+
192
+ `configure(enabled=False)`, `LUMA_OBSERVE_ENABLED=false` or
193
+ `OTEL_SDK_DISABLED=true` disables the complete SDK, including optional lifecycle
194
+ logging. With no OTLP endpoint configured, providers and background threads are
195
+ not started. Endpoint/header/protocol/sampler settings use the official `OTEL_*`
196
+ environment contract. This package exports `http/protobuf`.
197
+ Producers without a local endpoint still timestamp publication for worker queue
198
+ wait measurements and preserve carried context; explicit disablement leaves the
199
+ carrier untouched. `telemetry_disabled()` reads those explicit switches without
200
+ initializing a runtime.
201
+
202
+ Inspect `get_runtime().enabled` to check whether the runtime initialized.
203
+ `get_runtime().initialization_error` contains only the initialization exception's
204
+ class name when initialization fails; the runtime stays disabled. It does not
205
+ log configuration or exception contents on the business call path.
206
+
207
+ Defaults are a 512-span queue, batches of 64, a five-second trace export interval,
208
+ a 30-second metric interval, a 1.5-second HTTP export budget and a two-second
209
+ shutdown budget. Lifecycle JSON logging is opt-in with `lifecycle_logs=True` and
210
+ uses a bounded background queue. It writes INFO records to `luma_observe.events`
211
+ using application-configured logging handlers. Route that logger to stdout for
212
+ the existing Nomad shipper; a standalone process can configure it at startup:
213
+
214
+ ```python
215
+ import logging
216
+ import sys
217
+ from luma_observe import configure
218
+
219
+ logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s")
220
+ configure(lifecycle_logs=True)
221
+ ```
222
+
223
+ Existing applications should use their current logging configuration for this
224
+ logger. The SDK does not create another log store or collector.
225
+
226
+ Call `shutdown()` at the application's final process shutdown; atexit is also
227
+ registered. Celery uses its process shutdown signals. Closing one Taskiq broker
228
+ calls bounded `force_flush()` and does not stop telemetry shared by other brokers.
229
+ Applications may also call `force_flush(timeout=2)` before a known worker exit;
230
+ it returns whether the queued flush completed within its budget and never starts
231
+ a missing runtime. It does not close providers or prove downstream persistence.
232
+ Export failure can discard telemetry;
233
+ it does not make task execution wait for a collector response. Fault and load
234
+ acceptance still belongs to each application's integration rollout.
235
+
236
+ ## Metrics and dashboards
237
+
238
+ The shared Collector's Prometheus conversion exposes:
239
+
240
+ | Metric | Meaning |
241
+ | --- | --- |
242
+ | `task_executions_total` | Completed attempts by `outcome` |
243
+ | `task_duration_seconds_bucket` | Attempt duration histogram |
244
+ | `task_queue_wait_seconds_bucket` | Known enqueue-to-start duration histogram |
245
+ | `task_active` | Active attempts in the process |
246
+ | `worker_heartbeat_seconds` | Exporter process collection time as Unix seconds |
247
+ | `task_oldest_active_age_seconds` | Oldest active attempt age by task name, queue and framework |
248
+ | `task_active_tracking_overflow` | Active attempts beyond the bounded age tracker |
249
+
250
+ Task metric labels are `task_name`, `task_queue` and `task_framework`, plus outcome
251
+ for executions. Standard resource attributes supply `service_name`,
252
+ `deployment_environment_name` and the existing Luma resource dimensions.
253
+ Counters describe collected telemetry, not a financial or exact delivery ledger.
254
+ Keep process liveness and metric staleness in mind when aggregating active tasks.
255
+ Heartbeat and overflow are process-level gauges. Oldest age uses the same bounded
256
+ task dimensions as `task_active`: `task_name`, `task_queue` and `task_framework`.
257
+ It reports the oldest currently active attempt in each group, using a monotonic
258
+ clock. This identifies a long-running task type; it does not measure time since
259
+ the application's last progress event. Choose age thresholds for each task type.
260
+
261
+ Tracking is capped at 8192 active attempts per process and keeps no historical
262
+ task-key cache. Idle groups have no new age sample; overflow also suppresses age
263
+ samples. The example dashboard joins each age to `task_active > 0` with the same
264
+ process identity and all three task labels, then filters fresh heartbeat and zero
265
+ overflow. This excludes old age samples retained in the store's query lookback.
266
+ Only a fresh heartbeat plus an explicitly zero active count establishes local
267
+ idle; a missing, stale or overflowed age sample does not.
268
+
269
+ Heartbeat freshness shows that the process's export path is alive, not that its
270
+ event loop or business work is progressing. Local idle does not establish that a
271
+ broker queue is healthy or empty. Queue backlog and oldest unconsumed message age
272
+ remain application/broker-provided metrics.
273
+
274
+ Process instance identities remain distinct so simultaneous cumulative writers
275
+ do not overwrite one series. Worker recycling therefore creates historical
276
+ series; measure the workload's process churn, task dimensions and storage budget
277
+ before production acceptance. The protocol check records a synthetic multi-process
278
+ series inventory, not a production capacity result. See the
279
+ [dashboard guide](docs/dashboards.md) for freshness settings and query semantics.
280
+
281
+ Copy an example from [`examples/dashboards/`](examples/dashboards/), select your
282
+ service/metrics, and render it offline:
283
+
284
+ ```sh
285
+ luma-observe-dashboard application.json --output application-dashboard.json
286
+ ```
287
+
288
+ The output is ordinary Grafana JSON. Install the reviewed output under the existing
289
+ `/srv/luma/data/luma-observe/extensions/dashboards/` directory using the current
290
+ operator file-delivery workflow. Grafana discovers it in Applications within the
291
+ next 30-second scan. Each application owns its dashboard UID and query expressions.
292
+ Custom fields and panels do not require SDK or Control code changes.
293
+
294
+ Prometheus/vmalert rule YAML still goes under `extensions/rules/`; validate it with
295
+ the existing pinned vmalert binary and use existing Alertmanager routes. See the
296
+ [standard platform integration](../../docs/observe-applications.md) for scopes,
297
+ read APIs, authentication and extension delivery.
298
+
299
+ ## Validate and build
300
+
301
+ From the Luma repository root, in an isolated Python environment:
302
+
303
+ ```sh
304
+ python -m pip install -e './sdk/python[taskiq,celery,test]'
305
+ bash scripts/check-observe-sdk.sh --output-dir /tmp/luma-observe-sdk-dist
306
+ ```
307
+
308
+ The script runs SDK checks, builds wheel/sdist in a temporary source copy, validates
309
+ metadata and imports the installed wheel. It removes its temporary build/install
310
+ directories and retains only the explicitly requested distributions. It does not
311
+ contact production, deploy the observability stack or publish a package.
312
+ CI covers Python 3.10 with the minimum supported OTel 1.38.0 and Python 3.12
313
+ with current dependencies.
314
+
315
+ With Docker available, run the separate SDK protocol check:
316
+
317
+ ```sh
318
+ python -m pip install -e .
319
+ python scripts/check-observe-sdk-protocols.py --platform linux/amd64
320
+ ```
321
+
322
+ This CI job installs the SDK in an isolated container, exports native task/custom
323
+ metrics through the authenticated Collector to VictoriaMetrics, then provisions
324
+ the generated Grafana examples and queries their PromQL expressions against that
325
+ data. It uses synthetic credentials, no host ports, and removes its own containers,
326
+ volumes and temporary image tags. The existing platform protocol checks cover
327
+ the other shared ingestion/query paths. Application throughput, lifecycle coverage
328
+ and production data freshness still require integration acceptance after deployment.
@@ -0,0 +1,305 @@
1
+ # Luma Observe SDK
2
+
3
+ An optional Python integration package for the shared Luma observability stack.
4
+ It wraps official OpenTelemetry providers and OTLP/HTTP exporters, adds generic
5
+ task lifecycle adapters, and generates standard Grafana dashboard JSON from
6
+ application-owned configuration. It is versioned separately from `luma-infra`
7
+ and imports no Control, Django, business model or database code.
8
+
9
+ Python 3.10 or newer is required. See the [Chinese integration guide](docs/integration.zh-CN.md).
10
+
11
+ ## Install the distribution
12
+
13
+ The package name is `luma-observe-sdk`, initial source version `0.1.0`.
14
+ `Observe SDK Checks` builds wheel/sdist artifacts independently of Control for
15
+ review and validation; expiring CI artifacts are not a permanent dependency source.
16
+ The independent SDK release workflow publishes checked wheel/sdist files and
17
+ `SHA256SUMS` to a permanent GitHub Release under `observe-sdk-v<version>`.
18
+ After publication, use the exact wheel URL with its SHA-256 from the release
19
+ notes, or pin an accessible, immutable Git commit containing the SDK. See the
20
+ [independent SDK release guide](docs/releasing.md) for dependency formats.
21
+ Uncommitted source has no usable remote SHA; placeholders must be replaced
22
+ with the published asset hash or reviewed, pushed commit.
23
+
24
+ To inspect a downloaded wheel, add only the framework extra used by that process:
25
+
26
+ ```sh
27
+ python -m pip install './luma_observe_sdk-0.1.0-py3-none-any.whl[taskiq]'
28
+ # Celery processes use the [celery] extra; processes needing both use [taskiq,celery].
29
+ ```
30
+
31
+ PyPI publication is optional and disabled unless the repository variable
32
+ `OBSERVE_SDK_PYPI_ENABLED` is `true` and its Trusted Publisher is configured.
33
+ Once that exact version is published there, consumers can use
34
+ `luma-observe-sdk[taskiq]==0.1.0` with their normal package configuration.
35
+ The existing `v*` Luma release workflow publishes `luma-infra`, not this package.
36
+
37
+ ## Configure once per process
38
+
39
+ Luma-managed applications keep the existing Control-injected `OTEL_*` endpoint,
40
+ bearer headers and resource attributes. Existing `luma secret` values stay in use.
41
+ For task metrics, set `OTEL_METRICS_EXPORTER=otlp` in the application deployment;
42
+ Control's automatic default remains `none`.
43
+
44
+ ```python
45
+ from luma_observe import configure, span, task
46
+
47
+ configure(
48
+ service_name="document-worker",
49
+ resource_attributes={"deployment.environment.name": "production"},
50
+ )
51
+
52
+ def convert(document):
53
+ with task("document.convert", framework="manual", queue="documents",
54
+ attributes={"document.format": "pdf"}) as run:
55
+ with span("document.render"):
56
+ result = render_document(document)
57
+ if result.needs_review:
58
+ run.outcome = "failed"
59
+ return result
60
+ ```
61
+
62
+ `task` records one attempt, elapsed time and the supplied outcome; it preserves
63
+ the original return and exception. An uncaught exception records its type and a
64
+ failed outcome. Use `succeeded`, `failed`, `retrying`, `skipped` or `cancelled`.
65
+ The application defines business completion separately from framework success.
66
+ Custom attributes belong to spans; task metrics use the fixed dimensions below.
67
+
68
+ Inside an adapter-observed task, use `current_task()` to annotate the current
69
+ attempt without creating another task span:
70
+
71
+ ```python
72
+ from luma_observe import current_task
73
+
74
+ def mark_review_required():
75
+ run = current_task()
76
+ if run is not None:
77
+ run.business_outcome = "requires_review"
78
+ run.business_status = "pending"
79
+ ```
80
+
81
+ The two business fields accept application-defined strings up to 256 characters;
82
+ they appear as `task.business.outcome` and `task.business.status` in spans and
83
+ optional lifecycle logs, never as built-in metric labels. `set_outcome()` accepts
84
+ only the five standard outcomes above. A framework failure or an escaping
85
+ exception takes precedence over a successful business classification.
86
+ `current_task()` is local to the execution context, restores an outer task after a
87
+ nested task ends, and returns `None` outside a live task or in an inherited child
88
+ process until that child starts its own attempt.
89
+
90
+ `get_tracer()` and `get_meter()` return official OTel instruments from the SDK's
91
+ providers for additional application-defined spans and metrics. Keep custom metric
92
+ dimensions bounded; put execution IDs on spans or structured logs. Direct use of
93
+ official instrument APIs follows their normal semantics.
94
+
95
+ The `counter()` and `histogram()` helpers preserve the same metric API while
96
+ isolating instrumentation errors. Applications choose the metric names and fields:
97
+
98
+ ```python
99
+ from luma_observe import counter, histogram
100
+
101
+ completed = counter("document.completed")
102
+ size = histogram("document.pages", unit="1")
103
+ completed.add(1, {"document.format": "pdf", "outcome": "succeeded"})
104
+ size.record(12, {"document.format": "pdf"})
105
+ ```
106
+
107
+ The top-level `counter()` and `histogram()` handles can be declared before
108
+ `configure()` and reused by forked workers; each write uses that process's current
109
+ runtime. Instruments obtained from an explicit `Runtime`, `get_meter()` or
110
+ `get_tracer()` belong to that runtime and process: create them during worker setup.
111
+
112
+ ## Framework integration
113
+
114
+ Taskiq:
115
+
116
+ ```python
117
+ from luma_observe.instrumentation.taskiq import TaskiqMiddleware
118
+
119
+ broker.with_middlewares(TaskiqMiddleware(), existing_retry_middleware)
120
+ ```
121
+
122
+ Place observation first so completion cleanup runs after the existing middleware
123
+ chain. Execution state stays in the callback context, so later middleware can
124
+ copy or rebuild messages. The carrier contains propagation fields and a private,
125
+ serializable invocation ID; live execution objects are not serialized.
126
+ Existing broker transport, retries, result persistence and ACK ownership
127
+ remain with Taskiq. Celery uses its public process/task signals:
128
+
129
+ ```python
130
+ from luma_observe.instrumentation.celery import instrument_celery
131
+
132
+ observation = instrument_celery()
133
+ ```
134
+
135
+ Call this once during Celery app setup; `observation.uninstrument()` disconnects
136
+ the adapter after tasks drain. Providers initialize in the actual process.
137
+ Taskiq's default thread pool carries execution context; application functions
138
+ inside a custom process pool need their own initialization/propagation, while the
139
+ receiver's attempt can still be observed. Both adapters propagate standard W3C
140
+ context and baggage. Every framework execution starts a new trace with a Span Link
141
+ to its producer context, allowing the execution's completion to be sampled
142
+ independently of an earlier producer. Manual `task()` and `span()` retain their
143
+ normal parent semantics; explicit `context` and `links` are also supported.
144
+ The accompanying Collector source separates completed execution roots from early
145
+ child spans for tail sampling. This preserves the completed root summary, not
146
+ every child span in a long trace. Existing Collector deployments need that source
147
+ rebuilt and deployed before this sampling behavior is available; installing the
148
+ SDK alone does not update a running Collector.
149
+ The adapters create short producer preparation spans. Preparation
150
+ spans describe local message preparation, not broker receipt or send success.
151
+ Transport errors and reliable publication state remain the application's concern.
152
+ Use only one task adapter per framework; avoid instrumenting the same task twice
153
+ with another OTel adapter.
154
+
155
+ Adapters observe attempts that reach execution hooks. Revocation before execution
156
+ and hard process termination can leave no completed span. Taskiq's standard
157
+ SmartRetry `NoResultError` conversion identifies a retrying attempt; configurations
158
+ that keep the original error (`no_result_on_retry=False`) expose a failed attempt
159
+ without a definitive retry result. Queue state and business delivery evidence
160
+ remain separate from these attempt metrics.
161
+
162
+ With nested inline Taskiq in-memory execution, a child middleware error caught by
163
+ the parent can leave the child observation context active until the next SDK hook
164
+ restores the parent. Do not use that interim context as proof of parent progress.
165
+ Hard process termination still cannot run completion hooks.
166
+
167
+ ## Disable and bound telemetry
168
+
169
+ `configure(enabled=False)`, `LUMA_OBSERVE_ENABLED=false` or
170
+ `OTEL_SDK_DISABLED=true` disables the complete SDK, including optional lifecycle
171
+ logging. With no OTLP endpoint configured, providers and background threads are
172
+ not started. Endpoint/header/protocol/sampler settings use the official `OTEL_*`
173
+ environment contract. This package exports `http/protobuf`.
174
+ Producers without a local endpoint still timestamp publication for worker queue
175
+ wait measurements and preserve carried context; explicit disablement leaves the
176
+ carrier untouched. `telemetry_disabled()` reads those explicit switches without
177
+ initializing a runtime.
178
+
179
+ Inspect `get_runtime().enabled` to check whether the runtime initialized.
180
+ `get_runtime().initialization_error` contains only the initialization exception's
181
+ class name when initialization fails; the runtime stays disabled. It does not
182
+ log configuration or exception contents on the business call path.
183
+
184
+ Defaults are a 512-span queue, batches of 64, a five-second trace export interval,
185
+ a 30-second metric interval, a 1.5-second HTTP export budget and a two-second
186
+ shutdown budget. Lifecycle JSON logging is opt-in with `lifecycle_logs=True` and
187
+ uses a bounded background queue. It writes INFO records to `luma_observe.events`
188
+ using application-configured logging handlers. Route that logger to stdout for
189
+ the existing Nomad shipper; a standalone process can configure it at startup:
190
+
191
+ ```python
192
+ import logging
193
+ import sys
194
+ from luma_observe import configure
195
+
196
+ logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s")
197
+ configure(lifecycle_logs=True)
198
+ ```
199
+
200
+ Existing applications should use their current logging configuration for this
201
+ logger. The SDK does not create another log store or collector.
202
+
203
+ Call `shutdown()` at the application's final process shutdown; atexit is also
204
+ registered. Celery uses its process shutdown signals. Closing one Taskiq broker
205
+ calls bounded `force_flush()` and does not stop telemetry shared by other brokers.
206
+ Applications may also call `force_flush(timeout=2)` before a known worker exit;
207
+ it returns whether the queued flush completed within its budget and never starts
208
+ a missing runtime. It does not close providers or prove downstream persistence.
209
+ Export failure can discard telemetry;
210
+ it does not make task execution wait for a collector response. Fault and load
211
+ acceptance still belongs to each application's integration rollout.
212
+
213
+ ## Metrics and dashboards
214
+
215
+ The shared Collector's Prometheus conversion exposes:
216
+
217
+ | Metric | Meaning |
218
+ | --- | --- |
219
+ | `task_executions_total` | Completed attempts by `outcome` |
220
+ | `task_duration_seconds_bucket` | Attempt duration histogram |
221
+ | `task_queue_wait_seconds_bucket` | Known enqueue-to-start duration histogram |
222
+ | `task_active` | Active attempts in the process |
223
+ | `worker_heartbeat_seconds` | Exporter process collection time as Unix seconds |
224
+ | `task_oldest_active_age_seconds` | Oldest active attempt age by task name, queue and framework |
225
+ | `task_active_tracking_overflow` | Active attempts beyond the bounded age tracker |
226
+
227
+ Task metric labels are `task_name`, `task_queue` and `task_framework`, plus outcome
228
+ for executions. Standard resource attributes supply `service_name`,
229
+ `deployment_environment_name` and the existing Luma resource dimensions.
230
+ Counters describe collected telemetry, not a financial or exact delivery ledger.
231
+ Keep process liveness and metric staleness in mind when aggregating active tasks.
232
+ Heartbeat and overflow are process-level gauges. Oldest age uses the same bounded
233
+ task dimensions as `task_active`: `task_name`, `task_queue` and `task_framework`.
234
+ It reports the oldest currently active attempt in each group, using a monotonic
235
+ clock. This identifies a long-running task type; it does not measure time since
236
+ the application's last progress event. Choose age thresholds for each task type.
237
+
238
+ Tracking is capped at 8192 active attempts per process and keeps no historical
239
+ task-key cache. Idle groups have no new age sample; overflow also suppresses age
240
+ samples. The example dashboard joins each age to `task_active > 0` with the same
241
+ process identity and all three task labels, then filters fresh heartbeat and zero
242
+ overflow. This excludes old age samples retained in the store's query lookback.
243
+ Only a fresh heartbeat plus an explicitly zero active count establishes local
244
+ idle; a missing, stale or overflowed age sample does not.
245
+
246
+ Heartbeat freshness shows that the process's export path is alive, not that its
247
+ event loop or business work is progressing. Local idle does not establish that a
248
+ broker queue is healthy or empty. Queue backlog and oldest unconsumed message age
249
+ remain application/broker-provided metrics.
250
+
251
+ Process instance identities remain distinct so simultaneous cumulative writers
252
+ do not overwrite one series. Worker recycling therefore creates historical
253
+ series; measure the workload's process churn, task dimensions and storage budget
254
+ before production acceptance. The protocol check records a synthetic multi-process
255
+ series inventory, not a production capacity result. See the
256
+ [dashboard guide](docs/dashboards.md) for freshness settings and query semantics.
257
+
258
+ Copy an example from [`examples/dashboards/`](examples/dashboards/), select your
259
+ service/metrics, and render it offline:
260
+
261
+ ```sh
262
+ luma-observe-dashboard application.json --output application-dashboard.json
263
+ ```
264
+
265
+ The output is ordinary Grafana JSON. Install the reviewed output under the existing
266
+ `/srv/luma/data/luma-observe/extensions/dashboards/` directory using the current
267
+ operator file-delivery workflow. Grafana discovers it in Applications within the
268
+ next 30-second scan. Each application owns its dashboard UID and query expressions.
269
+ Custom fields and panels do not require SDK or Control code changes.
270
+
271
+ Prometheus/vmalert rule YAML still goes under `extensions/rules/`; validate it with
272
+ the existing pinned vmalert binary and use existing Alertmanager routes. See the
273
+ [standard platform integration](../../docs/observe-applications.md) for scopes,
274
+ read APIs, authentication and extension delivery.
275
+
276
+ ## Validate and build
277
+
278
+ From the Luma repository root, in an isolated Python environment:
279
+
280
+ ```sh
281
+ python -m pip install -e './sdk/python[taskiq,celery,test]'
282
+ bash scripts/check-observe-sdk.sh --output-dir /tmp/luma-observe-sdk-dist
283
+ ```
284
+
285
+ The script runs SDK checks, builds wheel/sdist in a temporary source copy, validates
286
+ metadata and imports the installed wheel. It removes its temporary build/install
287
+ directories and retains only the explicitly requested distributions. It does not
288
+ contact production, deploy the observability stack or publish a package.
289
+ CI covers Python 3.10 with the minimum supported OTel 1.38.0 and Python 3.12
290
+ with current dependencies.
291
+
292
+ With Docker available, run the separate SDK protocol check:
293
+
294
+ ```sh
295
+ python -m pip install -e .
296
+ python scripts/check-observe-sdk-protocols.py --platform linux/amd64
297
+ ```
298
+
299
+ This CI job installs the SDK in an isolated container, exports native task/custom
300
+ metrics through the authenticated Collector to VictoriaMetrics, then provisions
301
+ the generated Grafana examples and queries their PromQL expressions against that
302
+ data. It uses synthetic credentials, no host ports, and removes its own containers,
303
+ volumes and temporary image tags. The existing platform protocol checks cover
304
+ the other shared ingestion/query paths. Application throughput, lifecycle coverage
305
+ and production data freshness still require integration acceptance after deployment.