labcode 0.1.7__tar.gz → 0.1.8__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.
- {labcode-0.1.7 → labcode-0.1.8}/PKG-INFO +21 -5
- {labcode-0.1.7 → labcode-0.1.8}/README.md +17 -4
- {labcode-0.1.7 → labcode-0.1.8}/labcode/otel.py +8 -4
- labcode-0.1.8/labcode/otel_sila2.py +182 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2_instrument.py +49 -1
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/PKG-INFO +21 -5
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/SOURCES.txt +1 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/requires.txt +3 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/scm_file_list.json +1 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/scm_version.json +2 -2
- {labcode-0.1.7 → labcode-0.1.8}/pyproject.toml +23 -2
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel_child.py +40 -0
- labcode-0.1.8/tests/test_otel_grpc.py +153 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_sila2_instrument.py +52 -2
- labcode-0.1.7/labcode/otel_sila2.py +0 -68
- {labcode-0.1.7 → labcode-0.1.8}/.github/workflows/ci.yml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/.github/workflows/publish.yml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/.gitignore +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/LICENSE +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/MANIFEST.in +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/SPECIFICATIONS.md +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/README.md +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.boundary.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.observation.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.plan.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.svg +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.svg +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.boundary.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.workflow.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/render_plate_line.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/render_sila2_plate_cycle.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/run_all_sila2_examples.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/run_sila2_plate_cycle.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/run_sila2_seal.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.workflow.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.boundary.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.workflow.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.wrapped.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/__init__.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/__main__.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/_child.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/backend.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/cli.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/dialect.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/extension.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/idgen.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/objectid.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/probe.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/py.typed +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/record.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/run_cli.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/runner.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2_commands.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/dependency_links.txt +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/entry_points.txt +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/top_level.txt +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/setup.cfg +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/device_script.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/device_script.workflow.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/reroute_device.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/reroute_transporter.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/transport.env.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/transport.workflow.yaml +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_backend.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_cli.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_dialect.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_objectid.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel_sila2.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_probe.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_record.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_recording.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_run_cli.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_sila2.py +0 -0
- {labcode-0.1.7 → labcode-0.1.8}/tests/test_sila2_commands.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
|
|
5
5
|
Author-email: Kazunari Kaizu <kwaizu@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,17 +26,20 @@ Requires-Dist: ofplang-run>=0.1.13
|
|
|
26
26
|
Provides-Extra: test
|
|
27
27
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
28
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
|
|
29
|
+
Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "test"
|
|
29
30
|
Provides-Extra: dev
|
|
30
31
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
32
|
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
32
33
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
33
34
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
|
|
35
|
+
Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "dev"
|
|
34
36
|
Provides-Extra: sila2
|
|
35
37
|
Requires-Dist: sila2>=0.14; extra == "sila2"
|
|
36
38
|
Provides-Extra: otel
|
|
37
39
|
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
38
40
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
39
41
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
|
|
42
|
+
Requires-Dist: opentelemetry-instrumentation-grpc>=0.41b0; extra == "otel"
|
|
40
43
|
Dynamic: license-file
|
|
41
44
|
|
|
42
45
|
# labcode
|
|
@@ -110,9 +113,9 @@ What `lc run` brings of its own, beyond dispatching:
|
|
|
110
113
|
(§1.6). The client library itself is the `sila2` extra: `pip install labcode[sila2]`,
|
|
111
114
|
installed into whichever interpreter runs the scripts.
|
|
112
115
|
- **recording a run** — with `--trace`, what the run did is recorded as OpenTelemetry
|
|
113
|
-
traces: one trace per run, a span per operation, and
|
|
114
|
-
|
|
115
|
-
`pip install labcode[otel]`.
|
|
116
|
+
traces: one trace per run, a span per operation, and — measured inside the process that
|
|
117
|
+
issued them — a span per SiLA2 connection, per command, and per gRPC call underneath
|
|
118
|
+
either. Off by default; the extra is `pip install labcode[otel]`.
|
|
116
119
|
|
|
117
120
|
## Usage
|
|
118
121
|
|
|
@@ -198,14 +201,17 @@ pip install 'labcode[otel]'
|
|
|
198
201
|
lc run <workflow> --env <env> --trace --mission-id M-2026-001
|
|
199
202
|
```
|
|
200
203
|
|
|
201
|
-
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
204
|
+
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
205
|
+
starts (`lc run: recording this run as trace …`). What it holds:
|
|
202
206
|
|
|
203
207
|
```text
|
|
204
208
|
run mission.id, and the failure if it stopped on one
|
|
205
209
|
├─ process Seal which node, process and mode; the plan's interval;
|
|
206
210
|
│ │ which Objects it handled
|
|
207
211
|
│ ├─ sila2.connect the address, measured in the process that connected
|
|
212
|
+
│ │ └─ /…/SiLAService/GetFeatureDefinition one per feature, × however many
|
|
208
213
|
│ └─ sila2 SealerControl.Seal the command, from its start to its real completion
|
|
214
|
+
│ └─ /…/SealerControl/Seal the round trip that started it
|
|
209
215
|
└─ transport the route and the transporter
|
|
210
216
|
```
|
|
211
217
|
|
|
@@ -215,6 +221,16 @@ in the command spans, which are measured where the commands are issued. `ofp.obj
|
|
|
215
221
|
lists the `_id`s an operation handled, including one it created — which is what makes
|
|
216
222
|
"everything that happened to this plate" a single query.
|
|
217
223
|
|
|
224
|
+
The innermost layer is the gRPC calls themselves, each under the connection or the command
|
|
225
|
+
that issued it — so what a connection spends is broken down into the feature definitions it
|
|
226
|
+
had to fetch, and a command's span separates its round trip from the time the instrument
|
|
227
|
+
then took. It needs `grpcio`, which arrives with the `sila2` extra; without it the record is
|
|
228
|
+
the same minus that layer. Two things it does not do: an observable command's
|
|
229
|
+
execution-info subscription is **not** recorded (it is read on a thread of `sila2`'s own,
|
|
230
|
+
where it would land in a trace of its own, and its duration is the command's anyway), and
|
|
231
|
+
each recorded call **sends the trace context to the instrument** in its gRPC metadata, which
|
|
232
|
+
a SiLA2 server ignores as it does any key that is not SiLA Client Metadata.
|
|
233
|
+
|
|
218
234
|
Where the record goes is configured by the **standard `OTEL_*` environment variables**
|
|
219
235
|
(`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`, …), so
|
|
220
236
|
labcode adds no settings of its own; `service.name` falls back to `labcode` if nothing sets
|
|
@@ -69,9 +69,9 @@ What `lc run` brings of its own, beyond dispatching:
|
|
|
69
69
|
(§1.6). The client library itself is the `sila2` extra: `pip install labcode[sila2]`,
|
|
70
70
|
installed into whichever interpreter runs the scripts.
|
|
71
71
|
- **recording a run** — with `--trace`, what the run did is recorded as OpenTelemetry
|
|
72
|
-
traces: one trace per run, a span per operation, and
|
|
73
|
-
|
|
74
|
-
`pip install labcode[otel]`.
|
|
72
|
+
traces: one trace per run, a span per operation, and — measured inside the process that
|
|
73
|
+
issued them — a span per SiLA2 connection, per command, and per gRPC call underneath
|
|
74
|
+
either. Off by default; the extra is `pip install labcode[otel]`.
|
|
75
75
|
|
|
76
76
|
## Usage
|
|
77
77
|
|
|
@@ -157,14 +157,17 @@ pip install 'labcode[otel]'
|
|
|
157
157
|
lc run <workflow> --env <env> --trace --mission-id M-2026-001
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
160
|
+
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
161
|
+
starts (`lc run: recording this run as trace …`). What it holds:
|
|
161
162
|
|
|
162
163
|
```text
|
|
163
164
|
run mission.id, and the failure if it stopped on one
|
|
164
165
|
├─ process Seal which node, process and mode; the plan's interval;
|
|
165
166
|
│ │ which Objects it handled
|
|
166
167
|
│ ├─ sila2.connect the address, measured in the process that connected
|
|
168
|
+
│ │ └─ /…/SiLAService/GetFeatureDefinition one per feature, × however many
|
|
167
169
|
│ └─ sila2 SealerControl.Seal the command, from its start to its real completion
|
|
170
|
+
│ └─ /…/SealerControl/Seal the round trip that started it
|
|
168
171
|
└─ transport the route and the transporter
|
|
169
172
|
```
|
|
170
173
|
|
|
@@ -174,6 +177,16 @@ in the command spans, which are measured where the commands are issued. `ofp.obj
|
|
|
174
177
|
lists the `_id`s an operation handled, including one it created — which is what makes
|
|
175
178
|
"everything that happened to this plate" a single query.
|
|
176
179
|
|
|
180
|
+
The innermost layer is the gRPC calls themselves, each under the connection or the command
|
|
181
|
+
that issued it — so what a connection spends is broken down into the feature definitions it
|
|
182
|
+
had to fetch, and a command's span separates its round trip from the time the instrument
|
|
183
|
+
then took. It needs `grpcio`, which arrives with the `sila2` extra; without it the record is
|
|
184
|
+
the same minus that layer. Two things it does not do: an observable command's
|
|
185
|
+
execution-info subscription is **not** recorded (it is read on a thread of `sila2`'s own,
|
|
186
|
+
where it would land in a trace of its own, and its duration is the command's anyway), and
|
|
187
|
+
each recorded call **sends the trace context to the instrument** in its gRPC metadata, which
|
|
188
|
+
a SiLA2 server ignores as it does any key that is not SiLA Client Metadata.
|
|
189
|
+
|
|
177
190
|
Where the record goes is configured by the **standard `OTEL_*` environment variables**
|
|
178
191
|
(`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`, …), so
|
|
179
192
|
labcode adds no settings of its own; `service.name` falls back to `labcode` if nothing sets
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
This is the half that knows OpenTelemetry. It sets up the provider, records the run and its
|
|
4
4
|
operations, hands a child process what it needs to join the same trace, and -- in that child --
|
|
5
|
-
resumes from it and instruments the SiLA2 client (`labcode.sila2_instrument`
|
|
6
|
-
`labcode.otel_sila2`). Replacing it means writing one more module like
|
|
7
|
-
lines to `labcode.record`; see that module's docstring.
|
|
5
|
+
resumes from it and instruments the SiLA2 client (`labcode.sila2_instrument` and the gRPC calls
|
|
6
|
+
underneath it, both via `labcode.otel_sila2`). Replacing it means writing one more module like
|
|
7
|
+
this one and adding two lines to `labcode.record`; see that module's docstring.
|
|
8
8
|
|
|
9
9
|
**Configuration is OpenTelemetry's own.** The endpoint, headers, timeouts, resource attributes
|
|
10
10
|
and sampling all come from the standard ``OTEL_*`` environment variables, so an operator
|
|
@@ -47,7 +47,7 @@ from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
|
|
47
47
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
|
48
48
|
from opentelemetry.trace import Span, Status, StatusCode
|
|
49
49
|
|
|
50
|
-
from labcode.otel_sila2 import ATTR_ERROR_TYPE, OtelSink
|
|
50
|
+
from labcode.otel_sila2 import ATTR_ERROR_TYPE, OtelSink, instrument_grpc, uninstrument_grpc
|
|
51
51
|
from labcode.record import ATTR_MISSION, RUN_STOPPED, SPAN_RUN
|
|
52
52
|
from labcode.sila2_instrument import (
|
|
53
53
|
PROTOBUF_ENV,
|
|
@@ -281,6 +281,7 @@ class ChildSession:
|
|
|
281
281
|
|
|
282
282
|
def finish(self) -> None:
|
|
283
283
|
uninstrument_sila2()
|
|
284
|
+
uninstrument_grpc()
|
|
284
285
|
context.detach(self.token)
|
|
285
286
|
if self.owned:
|
|
286
287
|
self.provider.shutdown() # flushes what has not been exported yet
|
|
@@ -315,4 +316,7 @@ def resume_from_env(
|
|
|
315
316
|
carrier["tracestate"] = tracestate
|
|
316
317
|
token = context.attach(propagate.extract(carrier))
|
|
317
318
|
instrument_sila2(OtelSink(tracer=resolved.get_tracer("labcode.sila2")), targets=targets)
|
|
319
|
+
# Here, rather than anywhere later: what it wraps is the factory a channel comes from, so
|
|
320
|
+
# every client the script opens has to come after it. This is the process's startup.
|
|
321
|
+
instrument_grpc()
|
|
318
322
|
return ChildSession(provider=resolved, owned=owned, token=token)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The `Sink` that turns `labcode.sila2_instrument`'s measured units into OpenTelemetry spans.
|
|
2
|
+
|
|
3
|
+
The instrumentation decides *what* is worth measuring; this decides *how it is recorded*. Swapping
|
|
4
|
+
the recording backend means rewriting this file -- and leaving the instrumentation, where the
|
|
5
|
+
difficult part lives (which calls to intervene in, how an observable command's real extent is
|
|
6
|
+
observed), untouched.
|
|
7
|
+
|
|
8
|
+
**It configures no provider**, only `trace.get_tracer`. Choosing an exporter and the resource
|
|
9
|
+
attributes is the application's job (`labcode.otel` for a run; a UI that drives instruments
|
|
10
|
+
directly does it for itself). That is the OpenTelemetry convention, and it has a useful
|
|
11
|
+
consequence: with no provider configured the whole thing is a no-op, so a child process that is
|
|
12
|
+
not recording needs no flag of its own to check.
|
|
13
|
+
|
|
14
|
+
Spans are started **without becoming the current span** (`start_span`, not
|
|
15
|
+
`start_as_current_span`). An observable command's span stays open until its responses are
|
|
16
|
+
collected, and must not adopt the script code that runs meanwhile; wrapping the windows where an
|
|
17
|
+
RPC actually runs is the instrumentation's job, through `active`.
|
|
18
|
+
|
|
19
|
+
**It also records the transport underneath** (`instrument_grpc`), which is what those windows
|
|
20
|
+
were for: every gRPC call a SiLA2 client makes becomes a span under the connection or the command
|
|
21
|
+
that issued it, so a connection's cost breaks down into the feature definitions it fetched and a
|
|
22
|
+
command's into the round trip it really was. That half is OpenTelemetry's own gRPC instrumentation
|
|
23
|
+
rather than anything written here -- the only decision this module makes about it is which calls
|
|
24
|
+
are worth a span (`_unary_only`).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import warnings
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from contextlib import AbstractContextManager
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from opentelemetry import trace
|
|
36
|
+
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
|
|
37
|
+
|
|
38
|
+
from labcode.sila2_instrument import KIND_CLIENT
|
|
39
|
+
|
|
40
|
+
#: Measured-unit kind -> span kind. A call to an instrument is a CLIENT span; left unset it would
|
|
41
|
+
#: be INTERNAL, and the record would no longer say that it left this process.
|
|
42
|
+
_KINDS = {KIND_CLIENT: SpanKind.CLIENT}
|
|
43
|
+
|
|
44
|
+
#: Where a failure's kind goes (the OpenTelemetry convention).
|
|
45
|
+
ATTR_ERROR_TYPE = "error.type"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OtelSink:
|
|
49
|
+
"""`labcode.sila2_instrument.Sink`, recorded as OpenTelemetry spans."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, tracer: Any | None = None) -> None:
|
|
52
|
+
# Fetching the tracer eagerly is safe: the API returns one that does nothing until a
|
|
53
|
+
# provider is configured, and follows the provider once one is.
|
|
54
|
+
self._tracer = tracer if tracer is not None else trace.get_tracer("labcode.sila2")
|
|
55
|
+
|
|
56
|
+
def start(self, name: str, *, kind: str, attributes: Mapping[str, Any]) -> Span:
|
|
57
|
+
return self._tracer.start_span(
|
|
58
|
+
name, kind=_KINDS.get(kind, SpanKind.INTERNAL), attributes=dict(attributes)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def update(self, handle: Span, attributes: Mapping[str, Any]) -> None:
|
|
62
|
+
for key, value in attributes.items():
|
|
63
|
+
handle.set_attribute(key, value)
|
|
64
|
+
|
|
65
|
+
def end(
|
|
66
|
+
self, handle: Span, *, error_type: str | None = None, message: str | None = None
|
|
67
|
+
) -> None:
|
|
68
|
+
if error_type is not None:
|
|
69
|
+
handle.set_attribute(ATTR_ERROR_TYPE, error_type)
|
|
70
|
+
handle.set_status(Status(StatusCode.ERROR, message))
|
|
71
|
+
handle.end()
|
|
72
|
+
|
|
73
|
+
def active(self, handle: Span) -> AbstractContextManager[Any]:
|
|
74
|
+
# `end_on_exit=False`: this window only lends the current context. How long the span lives
|
|
75
|
+
# is the instrumentation's to decide -- an observable command opens two windows before it
|
|
76
|
+
# ends.
|
|
77
|
+
return trace.use_span(handle, end_on_exit=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- the transport underneath -------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: Whether **this module** wrapped gRPC's channel factories, and so whether unwrapping them is
|
|
84
|
+
#: its business. The same rule `labcode.otel.build_provider` follows for a provider: what another
|
|
85
|
+
#: program set up is that program's to take down.
|
|
86
|
+
_applied_here = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unary_only(client_info: Any) -> bool:
|
|
90
|
+
"""Which gRPC calls are recorded: **unary calls yes, server streams no.**
|
|
91
|
+
|
|
92
|
+
A SiLA2 observable command opens a server stream for its execution info and `sila2` consumes
|
|
93
|
+
it on a thread of its own, which makes recording that stream wrong in two ways. The span
|
|
94
|
+
would be opened on the first iteration -- on that thread, where nothing is current -- and so
|
|
95
|
+
would land in **a trace of its own** instead of under the command it belongs to, because a
|
|
96
|
+
context is per thread. And intercepting the call replaces the stream with a generator, which
|
|
97
|
+
no longer answers `cancel()`, so `sila2`'s own `cancel_execution_info_subscription` would
|
|
98
|
+
break on it. Letting the call through leaves both alone, and gives up nothing: how long the
|
|
99
|
+
subscription lasted is exactly what the command's own span already measures.
|
|
100
|
+
|
|
101
|
+
Nothing is filtered by *name*: the feature definitions a client fetches while it is being
|
|
102
|
+
built are recorded here, under the connection, which is why they are not measured a second
|
|
103
|
+
time as commands (see `labcode.sila2_instrument`).
|
|
104
|
+
"""
|
|
105
|
+
return not getattr(client_info, "is_server_stream", False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def instrument_grpc() -> bool:
|
|
109
|
+
"""Have this process's gRPC client calls recorded, and say whether they will be.
|
|
110
|
+
|
|
111
|
+
This is the one thing here that reads the **globally configured** provider rather than being
|
|
112
|
+
handed a tracer: OpenTelemetry's gRPC instrumentation takes its provider from the channel it
|
|
113
|
+
wraps, and a `sila2` client builds its own channel. So the application must have configured
|
|
114
|
+
a provider before this is called -- as `labcode.otel.resume_from_env` does -- and with none
|
|
115
|
+
configured the RPC spans are no-ops like everything else here.
|
|
116
|
+
|
|
117
|
+
**It must run before any channel exists.** What is wrapped is `grpc.insecure_channel` and
|
|
118
|
+
`grpc.secure_channel`, so a channel built before this returns is never intercepted; a child
|
|
119
|
+
process does it at startup, ahead of the script that opens the clients.
|
|
120
|
+
|
|
121
|
+
Returns `False`, and says nothing, when there is no gRPC instrumentation installed (or no
|
|
122
|
+
`grpcio` for it to instrument): the connection and command spans do not depend on it, and an
|
|
123
|
+
interpreter that drives no instruments is right not to carry it. It warns only for the
|
|
124
|
+
surprising case -- the instrumentation is here, and still could not be applied.
|
|
125
|
+
|
|
126
|
+
Channel factories somebody else already wrapped are **left as they are** (and reported as
|
|
127
|
+
recording): reconfiguring another program's instrumentation, and later removing it, is not
|
|
128
|
+
this module's to do.
|
|
129
|
+
"""
|
|
130
|
+
global _applied_here
|
|
131
|
+
try:
|
|
132
|
+
from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient
|
|
133
|
+
except ImportError:
|
|
134
|
+
return False # no gRPC instrumentation here, or no gRPC under it
|
|
135
|
+
if _channel_factories_wrapped():
|
|
136
|
+
return True
|
|
137
|
+
try:
|
|
138
|
+
# The client only. A run drives instruments; it serves nothing.
|
|
139
|
+
GrpcInstrumentorClient(filter_=_unary_only).instrument()
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
_warn_not_applied(str(exc))
|
|
142
|
+
return False
|
|
143
|
+
if not _channel_factories_wrapped():
|
|
144
|
+
# `instrument()` reports a dependency it cannot satisfy by logging and returning, so
|
|
145
|
+
# what it did has to be read off the world rather than off its return value.
|
|
146
|
+
_warn_not_applied("gRPC's channel factories were left as they were")
|
|
147
|
+
return False
|
|
148
|
+
_applied_here = True
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def uninstrument_grpc() -> None:
|
|
153
|
+
"""Put gRPC's channel factories back, if this module was what wrapped them."""
|
|
154
|
+
global _applied_here
|
|
155
|
+
if not _applied_here:
|
|
156
|
+
return
|
|
157
|
+
_applied_here = False
|
|
158
|
+
with contextlib.suppress(Exception):
|
|
159
|
+
from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient
|
|
160
|
+
|
|
161
|
+
GrpcInstrumentorClient().uninstrument()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _channel_factories_wrapped() -> bool:
|
|
165
|
+
"""Whether gRPC's channel factories are intercepted -- a fact about the process, which is
|
|
166
|
+
what matters here, rather than about the instrumentor's own bookkeeping."""
|
|
167
|
+
try:
|
|
168
|
+
import grpc
|
|
169
|
+
except ImportError:
|
|
170
|
+
return False
|
|
171
|
+
return all(
|
|
172
|
+
hasattr(getattr(grpc, name, None), "__wrapped__")
|
|
173
|
+
for name in ("insecure_channel", "secure_channel")
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _warn_not_applied(reason: str) -> None:
|
|
178
|
+
warnings.warn(
|
|
179
|
+
f"labcode: cannot instrument gRPC, so this run records no RPCs under its SiLA2 "
|
|
180
|
+
f"calls: {reason}",
|
|
181
|
+
stacklevel=3,
|
|
182
|
+
)
|
|
@@ -6,6 +6,13 @@ themselves, and takes **one connection and one command** as the units worth meas
|
|
|
6
6
|
Feature or Command therefore becomes measurable by existing, with no telemetry code added to any
|
|
7
7
|
script.
|
|
8
8
|
|
|
9
|
+
**Not everything a client does is worth a unit.** Building one makes `sila2` fetch the definition
|
|
10
|
+
of every feature the server implements -- nine RPCs for one machine in the reference lab, and over
|
|
11
|
+
half of everything a real run recorded. Those are not measured (`_building_a_client`), because
|
|
12
|
+
they are the same RPCs a gRPC instrumentation records, and it records them *under* the connection;
|
|
13
|
+
measuring them here as well would count one call twice. What they cost stays visible as most of
|
|
14
|
+
`sila2.connect`, which is a unit.
|
|
15
|
+
|
|
9
16
|
**It does not know where the record goes.** Turning a measured unit into a record is the `Sink`'s
|
|
10
17
|
job (`labcode.otel_sila2` is the OpenTelemetry one); what lives here is only the *semantics* --
|
|
11
18
|
which calls are worth measuring, when one begins, and when it has really ended. Replacing the
|
|
@@ -335,6 +342,37 @@ def _active(handle: Any) -> Iterator[None]:
|
|
|
335
342
|
entered.__exit__(None, None, None)
|
|
336
343
|
|
|
337
344
|
|
|
345
|
+
#: Whether this thread is inside a client's construction. Per thread, because a script may open
|
|
346
|
+
#: clients from several.
|
|
347
|
+
_building = threading.local()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@contextlib.contextmanager
|
|
351
|
+
def _building_a_client() -> Iterator[None]:
|
|
352
|
+
"""The stretch in which `sila2` is building a client, during which **commands are not
|
|
353
|
+
measured**.
|
|
354
|
+
|
|
355
|
+
What happens in there is the fetching of every feature definition the server implements --
|
|
356
|
+
nine RPCs for one machine in the reference lab, and 54 of the 101 units one real run
|
|
357
|
+
recorded before this. They are left to the gRPC instrumentation that records the same RPCs,
|
|
358
|
+
under the connection they belong to; measuring them here as well would be one call counted
|
|
359
|
+
twice, and an RPC path is a truer name for a transport than a command name is. What they
|
|
360
|
+
cost stays visible as most of `sila2.connect`.
|
|
361
|
+
|
|
362
|
+
Recognised by *when* they happen rather than by what they are called: a construction is a
|
|
363
|
+
fact, whereas a name is a guess about what `sila2` fetches in there."""
|
|
364
|
+
depth = getattr(_building, "depth", 0)
|
|
365
|
+
_building.depth = depth + 1
|
|
366
|
+
try:
|
|
367
|
+
yield
|
|
368
|
+
finally:
|
|
369
|
+
_building.depth = depth
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _inside_a_client_build() -> bool:
|
|
373
|
+
return getattr(_building, "depth", 0) > 0
|
|
374
|
+
|
|
375
|
+
|
|
338
376
|
def _remember(handle: Any) -> None:
|
|
339
377
|
state = _state
|
|
340
378
|
if state is None or handle is None:
|
|
@@ -438,9 +476,15 @@ def _patch_client_init(state: _State) -> None:
|
|
|
438
476
|
attributes[ATTR_ADDRESS] = str(address)
|
|
439
477
|
if port is not None:
|
|
440
478
|
attributes[ATTR_PORT] = port
|
|
479
|
+
# Always measured: it is a cost every operation pays and cannot avoid (0.4-0.9 s against
|
|
480
|
+
# the reference lab, 16% of one run's wall clock), it is not recoverable from anything
|
|
481
|
+
# else the record holds, and it is what a later gRPC instrumentation hangs its per-RPC
|
|
482
|
+
# spans from.
|
|
441
483
|
handle = _start(SPAN_CONNECT, attributes)
|
|
442
484
|
try:
|
|
443
|
-
|
|
485
|
+
# Everything `sila2` fetches to build the client happens in here, which is how
|
|
486
|
+
# those commands are recognised (`_building_a_client`).
|
|
487
|
+
with _active(handle), _building_a_client():
|
|
444
488
|
original(self, *args, **kwargs)
|
|
445
489
|
except BaseException as exc:
|
|
446
490
|
_end(handle, error_type=_error_type(exc), message=str(exc))
|
|
@@ -457,6 +501,8 @@ def _patch_unobservable_call(state: _State) -> None:
|
|
|
457
501
|
|
|
458
502
|
@functools.wraps(original)
|
|
459
503
|
def __call__(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
504
|
+
if _inside_a_client_build():
|
|
505
|
+
return original(self, *args, **kwargs) # not a unit; see `_building_a_client`
|
|
460
506
|
name, attributes = _command_span(self)
|
|
461
507
|
handle = _start(name, attributes)
|
|
462
508
|
try:
|
|
@@ -478,6 +524,8 @@ def _patch_observable_call(state: _State) -> None:
|
|
|
478
524
|
|
|
479
525
|
@functools.wraps(original)
|
|
480
526
|
def __call__(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
527
|
+
if _inside_a_client_build():
|
|
528
|
+
return original(self, *args, **kwargs) # not a unit; see `_building_a_client`
|
|
481
529
|
name, attributes = _command_span(self)
|
|
482
530
|
handle = _start(name, attributes)
|
|
483
531
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
|
|
5
5
|
Author-email: Kazunari Kaizu <kwaizu@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,17 +26,20 @@ Requires-Dist: ofplang-run>=0.1.13
|
|
|
26
26
|
Provides-Extra: test
|
|
27
27
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
28
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
|
|
29
|
+
Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "test"
|
|
29
30
|
Provides-Extra: dev
|
|
30
31
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
32
|
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
32
33
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
33
34
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
|
|
35
|
+
Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "dev"
|
|
34
36
|
Provides-Extra: sila2
|
|
35
37
|
Requires-Dist: sila2>=0.14; extra == "sila2"
|
|
36
38
|
Provides-Extra: otel
|
|
37
39
|
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
38
40
|
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
39
41
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
|
|
42
|
+
Requires-Dist: opentelemetry-instrumentation-grpc>=0.41b0; extra == "otel"
|
|
40
43
|
Dynamic: license-file
|
|
41
44
|
|
|
42
45
|
# labcode
|
|
@@ -110,9 +113,9 @@ What `lc run` brings of its own, beyond dispatching:
|
|
|
110
113
|
(§1.6). The client library itself is the `sila2` extra: `pip install labcode[sila2]`,
|
|
111
114
|
installed into whichever interpreter runs the scripts.
|
|
112
115
|
- **recording a run** — with `--trace`, what the run did is recorded as OpenTelemetry
|
|
113
|
-
traces: one trace per run, a span per operation, and
|
|
114
|
-
|
|
115
|
-
`pip install labcode[otel]`.
|
|
116
|
+
traces: one trace per run, a span per operation, and — measured inside the process that
|
|
117
|
+
issued them — a span per SiLA2 connection, per command, and per gRPC call underneath
|
|
118
|
+
either. Off by default; the extra is `pip install labcode[otel]`.
|
|
116
119
|
|
|
117
120
|
## Usage
|
|
118
121
|
|
|
@@ -198,14 +201,17 @@ pip install 'labcode[otel]'
|
|
|
198
201
|
lc run <workflow> --env <env> --trace --mission-id M-2026-001
|
|
199
202
|
```
|
|
200
203
|
|
|
201
|
-
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
204
|
+
One run is one trace, and the id it can be found by is printed to stderr as the run
|
|
205
|
+
starts (`lc run: recording this run as trace …`). What it holds:
|
|
202
206
|
|
|
203
207
|
```text
|
|
204
208
|
run mission.id, and the failure if it stopped on one
|
|
205
209
|
├─ process Seal which node, process and mode; the plan's interval;
|
|
206
210
|
│ │ which Objects it handled
|
|
207
211
|
│ ├─ sila2.connect the address, measured in the process that connected
|
|
212
|
+
│ │ └─ /…/SiLAService/GetFeatureDefinition one per feature, × however many
|
|
208
213
|
│ └─ sila2 SealerControl.Seal the command, from its start to its real completion
|
|
214
|
+
│ └─ /…/SealerControl/Seal the round trip that started it
|
|
209
215
|
└─ transport the route and the transporter
|
|
210
216
|
```
|
|
211
217
|
|
|
@@ -215,6 +221,16 @@ in the command spans, which are measured where the commands are issued. `ofp.obj
|
|
|
215
221
|
lists the `_id`s an operation handled, including one it created — which is what makes
|
|
216
222
|
"everything that happened to this plate" a single query.
|
|
217
223
|
|
|
224
|
+
The innermost layer is the gRPC calls themselves, each under the connection or the command
|
|
225
|
+
that issued it — so what a connection spends is broken down into the feature definitions it
|
|
226
|
+
had to fetch, and a command's span separates its round trip from the time the instrument
|
|
227
|
+
then took. It needs `grpcio`, which arrives with the `sila2` extra; without it the record is
|
|
228
|
+
the same minus that layer. Two things it does not do: an observable command's
|
|
229
|
+
execution-info subscription is **not** recorded (it is read on a thread of `sila2`'s own,
|
|
230
|
+
where it would land in a trace of its own, and its duration is the command's anyway), and
|
|
231
|
+
each recorded call **sends the trace context to the instrument** in its gRPC metadata, which
|
|
232
|
+
a SiLA2 server ignores as it does any key that is not SiLA Client Metadata.
|
|
233
|
+
|
|
218
234
|
Where the record goes is configured by the **standard `OTEL_*` environment variables**
|
|
219
235
|
(`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`, …), so
|
|
220
236
|
labcode adds no settings of its own; `service.name` falls back to `labcode` if nothing sets
|
|
@@ -7,11 +7,13 @@ pytest>=7.0
|
|
|
7
7
|
ruff>=0.16
|
|
8
8
|
mypy>=1.11
|
|
9
9
|
opentelemetry-sdk>=1.20
|
|
10
|
+
opentelemetry-instrumentation-grpc[instruments]>=0.41b0
|
|
10
11
|
|
|
11
12
|
[otel]
|
|
12
13
|
opentelemetry-api>=1.20
|
|
13
14
|
opentelemetry-sdk>=1.20
|
|
14
15
|
opentelemetry-exporter-otlp-proto-http>=1.20
|
|
16
|
+
opentelemetry-instrumentation-grpc>=0.41b0
|
|
15
17
|
|
|
16
18
|
[sila2]
|
|
17
19
|
sila2>=0.14
|
|
@@ -19,3 +21,4 @@ sila2>=0.14
|
|
|
19
21
|
[test]
|
|
20
22
|
pytest>=7.0
|
|
21
23
|
opentelemetry-sdk>=1.20
|
|
24
|
+
opentelemetry-instrumentation-grpc[instruments]>=0.41b0
|
|
@@ -50,8 +50,22 @@ Repository = "https://github.com/ofplang/labcode"
|
|
|
50
50
|
# skipped test asserts nothing -- so CI has to be able to run them. And mypy is configured
|
|
51
51
|
# with `ignore_missing_imports`, which quietly turns an absent SDK's types into `Any`: that
|
|
52
52
|
# is how two real type errors in `labcode/otel.py` survived until the SDK was installed.
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# The gRPC instrumentation is here for the first of those reasons, and it is asked for
|
|
54
|
+
# *with* its own `instruments` extra -- meaning `grpcio` -- because what the tests check is
|
|
55
|
+
# that a server-streaming call is passed through untouched, and there is no interceptor to
|
|
56
|
+
# hand a call to without the library it intercepts.
|
|
57
|
+
test = [
|
|
58
|
+
"pytest>=7.0",
|
|
59
|
+
"opentelemetry-sdk>=1.20",
|
|
60
|
+
"opentelemetry-instrumentation-grpc[instruments]>=0.41b0",
|
|
61
|
+
]
|
|
62
|
+
dev = [
|
|
63
|
+
"pytest>=7.0",
|
|
64
|
+
"ruff>=0.16",
|
|
65
|
+
"mypy>=1.11",
|
|
66
|
+
"opentelemetry-sdk>=1.20",
|
|
67
|
+
"opentelemetry-instrumentation-grpc[instruments]>=0.41b0",
|
|
68
|
+
]
|
|
55
69
|
# Only the SiLA2 integration example needs a SiLA2 client, and it needs it in whatever
|
|
56
70
|
# interpreter runs the scripts: labcode launches each one with `sys.executable`. It is an
|
|
57
71
|
# extra rather than a dependency because labcode itself never speaks SiLA2 -- an environment
|
|
@@ -65,10 +79,17 @@ sila2 = ["sila2>=0.14"]
|
|
|
65
79
|
# The exporter is constructed with no arguments at all, so it asks for nothing newer; it is
|
|
66
80
|
# the HTTP one deliberately, so that nothing here constrains the `grpcio` that `sila2`
|
|
67
81
|
# brings into the same process.
|
|
82
|
+
# The gRPC instrumentation is what puts each RPC under the connection or the command that
|
|
83
|
+
# issued it -- the feature definitions a connection fetches, and a command's real round
|
|
84
|
+
# trip. It brings no `grpcio` of its own (its floor on one is an extra), which is right
|
|
85
|
+
# here: the `grpcio` in this process is whichever one `sila2` brought. `0.41b0` is the
|
|
86
|
+
# oldest release with the `filter_` this uses, and it accepts `opentelemetry-api ~= 1.12`,
|
|
87
|
+
# so it does not pull the floors above upwards.
|
|
68
88
|
otel = [
|
|
69
89
|
"opentelemetry-api>=1.20",
|
|
70
90
|
"opentelemetry-sdk>=1.20",
|
|
71
91
|
"opentelemetry-exporter-otlp-proto-http>=1.20",
|
|
92
|
+
"opentelemetry-instrumentation-grpc>=0.41b0",
|
|
72
93
|
]
|
|
73
94
|
|
|
74
95
|
[project.scripts]
|
|
@@ -167,3 +167,43 @@ def test_a_recording_child_gets_the_protobuf_implementation_sila2_needs(tmp_path
|
|
|
167
167
|
|
|
168
168
|
names = [span["name"] for span in _spans(tmp_path)]
|
|
169
169
|
assert "protobuf python" in names, f"the child resolved something else: {names}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_a_recording_child_has_its_grpc_calls_instrumented(tmp_path, monkeypatch):
|
|
173
|
+
"""The RPCs underneath a SiLA2 call are recorded in the child, and the wrapping has to be in
|
|
174
|
+
place before the script opens a client -- so what this checks is that the *startup* did it,
|
|
175
|
+
not that some later call did. The child reports what it found, as a span name."""
|
|
176
|
+
pytest.importorskip("ofplang.schedule", reason="ofplang-schedule not installed")
|
|
177
|
+
pytest.importorskip("grpc", reason="grpcio is not installed")
|
|
178
|
+
pytest.importorskip(
|
|
179
|
+
"opentelemetry.instrumentation.grpc", reason="the gRPC instrumentation is not installed"
|
|
180
|
+
)
|
|
181
|
+
from labcode.runner import LabcodeRunner
|
|
182
|
+
|
|
183
|
+
environment = tmp_path / "env.yaml"
|
|
184
|
+
environment.write_text(
|
|
185
|
+
TENV.read_text(encoding="utf-8").replace(
|
|
186
|
+
"moved = (view, from_spot, to_spot, transporter)",
|
|
187
|
+
"import grpc; "
|
|
188
|
+
"from opentelemetry import trace; "
|
|
189
|
+
"state = 'wrapped' if hasattr(grpc.insecure_channel, '__wrapped__') else 'plain'; "
|
|
190
|
+
"trace.get_tracer('child').start_span('grpc ' + state).end()",
|
|
191
|
+
),
|
|
192
|
+
encoding="utf-8",
|
|
193
|
+
)
|
|
194
|
+
monkeypatch.setenv("LC_TRACE_FILE", str(tmp_path / "record.jsonl"))
|
|
195
|
+
monkeypatch.setenv("OTEL_TRACES_EXPORTER", "none")
|
|
196
|
+
|
|
197
|
+
clock = FakeClock()
|
|
198
|
+
runner = LabcodeRunner(
|
|
199
|
+
TWF, str(environment), seconds_per_tick=0.001, monotonic=clock.monotonic,
|
|
200
|
+
sleep=clock.sleep, random_seed=0, running_task_margin=1, trace=True,
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
runner.run()
|
|
204
|
+
finally:
|
|
205
|
+
runner.sim.close()
|
|
206
|
+
assert not runner.failed
|
|
207
|
+
|
|
208
|
+
names = [span["name"] for span in _spans(tmp_path)]
|
|
209
|
+
assert "grpc wrapped" in names, f"the child's gRPC calls are not recorded: {names}"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Recording the gRPC calls underneath a SiLA2 call (`labcode.otel_sila2`).
|
|
2
|
+
|
|
3
|
+
The spans themselves are OpenTelemetry's own gRPC instrumentation, and are not this project's to
|
|
4
|
+
test. What *is* this project's is the one decision it makes about them -- which calls are worth a
|
|
5
|
+
span -- and the two mechanical facts that decision rests on: that a unary call is recorded under
|
|
6
|
+
whatever is current, and that a **server-streaming** call comes back exactly as it went in.
|
|
7
|
+
|
|
8
|
+
That second one is the point. A SiLA2 observable command subscribes to its execution info over a
|
|
9
|
+
server stream that `sila2` reads on a thread of its own, so a span for it would open where nothing
|
|
10
|
+
is current and land in a trace of its own -- and the interception would hand `sila2` a generator
|
|
11
|
+
in place of a stream it expects to be able to cancel. Both are avoided by not intercepting it, and
|
|
12
|
+
this asserts that the object handed back is the very one the call produced.
|
|
13
|
+
|
|
14
|
+
Skipped unless the gRPC instrumentation is installed, with the `grpcio` it intercepts.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from types import SimpleNamespace
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
pytest.importorskip("opentelemetry.sdk", reason="the otel extra is not installed")
|
|
24
|
+
pytest.importorskip(
|
|
25
|
+
"opentelemetry.instrumentation.grpc", reason="the gRPC instrumentation is not installed"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
import grpc # noqa: E402
|
|
29
|
+
from opentelemetry.instrumentation.grpc import client_interceptor # noqa: E402
|
|
30
|
+
from opentelemetry.sdk.trace import TracerProvider # noqa: E402
|
|
31
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402
|
|
32
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
|
|
33
|
+
InMemorySpanExporter,
|
|
34
|
+
)
|
|
35
|
+
from opentelemetry.trace import SpanKind # noqa: E402
|
|
36
|
+
|
|
37
|
+
from labcode.otel_sila2 import ( # noqa: E402
|
|
38
|
+
_unary_only,
|
|
39
|
+
instrument_grpc,
|
|
40
|
+
uninstrument_grpc,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
#: A real SiLA2 method path: the feature definitions a client fetches while it is being built.
|
|
44
|
+
#: They are deliberately *not* filtered out -- recorded here, under the connection, they are the
|
|
45
|
+
#: reason `labcode.sila2_instrument` does not measure them a second time as commands.
|
|
46
|
+
FETCH = "/sila2.org.silastandard.core.silaservice.v1.SiLAService/GetFeatureDefinition"
|
|
47
|
+
|
|
48
|
+
#: What an observable command's execution-info subscription looks like to a filter.
|
|
49
|
+
INFO_STREAM = "/sila2.org.silastandard.examples.sealer.v1.SealerControl/Seal_Info"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def recorded():
|
|
54
|
+
"""``(interceptor, exporter)`` recording into memory, filtered as labcode filters."""
|
|
55
|
+
exporter = InMemorySpanExporter()
|
|
56
|
+
provider = TracerProvider()
|
|
57
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
58
|
+
interceptor = client_interceptor(tracer_provider=provider, filter_=_unary_only)
|
|
59
|
+
return interceptor, exporter
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _unary_call(full_method: str = FETCH) -> SimpleNamespace:
|
|
63
|
+
return SimpleNamespace(full_method=full_method, timeout=None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _stream_call(full_method: str = INFO_STREAM, **kwargs) -> SimpleNamespace:
|
|
67
|
+
return SimpleNamespace(
|
|
68
|
+
full_method=full_method,
|
|
69
|
+
is_client_stream=kwargs.get("is_client_stream", False),
|
|
70
|
+
is_server_stream=kwargs.get("is_server_stream", True),
|
|
71
|
+
timeout=None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_a_unary_call_is_recorded_as_a_span_named_by_its_method(recorded):
|
|
76
|
+
interceptor, exporter = recorded
|
|
77
|
+
|
|
78
|
+
result = interceptor.intercept_unary("request", (), _unary_call(), lambda r, m: "response")
|
|
79
|
+
|
|
80
|
+
assert result == "response"
|
|
81
|
+
(span,) = exporter.get_finished_spans()
|
|
82
|
+
# The name is the method path, which is how a fetch of a feature definition can be told
|
|
83
|
+
# from the command that follows it without either one being named twice.
|
|
84
|
+
assert span.name == FETCH
|
|
85
|
+
assert span.kind is SpanKind.CLIENT
|
|
86
|
+
assert span.attributes["rpc.system"] == "grpc"
|
|
87
|
+
assert span.attributes["rpc.method"] == "GetFeatureDefinition"
|
|
88
|
+
assert span.attributes["rpc.service"] == FETCH.lstrip("/").split("/")[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_a_recorded_call_carries_the_trace_context_to_the_instrument(recorded):
|
|
92
|
+
"""A recorded RPC sends the current trace context in its gRPC metadata -- a fact worth
|
|
93
|
+
asserting because it is the one thing recording changes on the wire. A SiLA2 server ignores
|
|
94
|
+
metadata that is not a SiLA Client Metadata key."""
|
|
95
|
+
interceptor, _exporter = recorded
|
|
96
|
+
seen: dict[str, str] = {}
|
|
97
|
+
|
|
98
|
+
def invoker(request, metadata):
|
|
99
|
+
seen.update(dict(metadata))
|
|
100
|
+
return "response"
|
|
101
|
+
|
|
102
|
+
interceptor.intercept_unary("request", (), _unary_call(), invoker)
|
|
103
|
+
|
|
104
|
+
assert "traceparent" in seen, f"nothing was propagated: {seen}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_a_server_stream_is_handed_back_exactly_as_it_came(recorded):
|
|
108
|
+
interceptor, exporter = recorded
|
|
109
|
+
stream = object() # stands in for the rendezvous `sila2` keeps, and cancels
|
|
110
|
+
|
|
111
|
+
result = interceptor.intercept_stream("request", (), _stream_call(), lambda r, m: stream)
|
|
112
|
+
|
|
113
|
+
assert result is stream, "the stream was wrapped, so it no longer answers cancel()"
|
|
114
|
+
assert exporter.get_finished_spans() == ()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_only_a_server_stream_is_left_out():
|
|
118
|
+
"""The filter reads the one fact that distinguishes the subscription: a unary call has no
|
|
119
|
+
such attribute at all, which is why the question is asked with a default."""
|
|
120
|
+
assert _unary_only(_unary_call()) is True
|
|
121
|
+
assert _unary_only(_stream_call(is_server_stream=False, is_client_stream=True)) is True
|
|
122
|
+
assert _unary_only(_stream_call()) is False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_instrumenting_wraps_the_channel_factories_and_undoes_it():
|
|
126
|
+
plain = (grpc.insecure_channel, grpc.secure_channel)
|
|
127
|
+
try:
|
|
128
|
+
assert instrument_grpc() is True
|
|
129
|
+
# A channel can only be intercepted if the factory it comes from is, which is why this
|
|
130
|
+
# has to happen before any client exists.
|
|
131
|
+
assert grpc.insecure_channel is not plain[0]
|
|
132
|
+
assert hasattr(grpc.insecure_channel, "__wrapped__")
|
|
133
|
+
assert hasattr(grpc.secure_channel, "__wrapped__")
|
|
134
|
+
finally:
|
|
135
|
+
uninstrument_grpc()
|
|
136
|
+
assert (grpc.insecure_channel, grpc.secure_channel) == plain
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_factories_somebody_else_wrapped_are_left_alone(monkeypatch):
|
|
140
|
+
"""Reporting that RPCs are recorded, and not touching them: another program's
|
|
141
|
+
instrumentation is its own to configure and its own to remove."""
|
|
142
|
+
|
|
143
|
+
class AlreadyWrapped:
|
|
144
|
+
__wrapped__ = None
|
|
145
|
+
|
|
146
|
+
theirs = AlreadyWrapped()
|
|
147
|
+
monkeypatch.setattr(grpc, "insecure_channel", theirs)
|
|
148
|
+
monkeypatch.setattr(grpc, "secure_channel", theirs)
|
|
149
|
+
|
|
150
|
+
assert instrument_grpc() is True
|
|
151
|
+
uninstrument_grpc()
|
|
152
|
+
assert grpc.insecure_channel is theirs
|
|
153
|
+
assert grpc.secure_channel is theirs
|
|
@@ -106,15 +106,22 @@ class NotFinished(Exception):
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
class FakeClient:
|
|
109
|
-
"""Stands in for `SilaClient`: constructing one *is* the connection.
|
|
109
|
+
"""Stands in for `SilaClient`: constructing one *is* the connection.
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
`during_init` stands in for what `sila2` does while it builds one -- fetching the
|
|
112
|
+
definition of every feature the server implements, as commands."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self, address, port, *, insecure=False, log=None, fail=None, during_init=None
|
|
116
|
+
) -> None:
|
|
112
117
|
if log is not None:
|
|
113
118
|
log.append(("connecting", address))
|
|
114
119
|
if fail is not None:
|
|
115
120
|
raise fail
|
|
116
121
|
self.address = address
|
|
117
122
|
self.port = port
|
|
123
|
+
if during_init is not None:
|
|
124
|
+
during_init(self)
|
|
118
125
|
|
|
119
126
|
|
|
120
127
|
class FakeCommandDef:
|
|
@@ -490,3 +497,46 @@ def test_the_real_sila2_still_has_the_intervention_points():
|
|
|
490
497
|
command = SiLAServiceFeature["GetFeatureDefinition"]
|
|
491
498
|
assert command._identifier == "GetFeatureDefinition"
|
|
492
499
|
assert str(command.fully_qualified_identifier).endswith("/Command/GetFeatureDefinition")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# -- what building a client is not measured for -----------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _client_that_fetches(sink: RecordingSink, results: list) -> FakeClient:
|
|
506
|
+
"""A client whose construction issues a command, as `sila2` does when it fetches the
|
|
507
|
+
feature definitions of the server it has just reached."""
|
|
508
|
+
|
|
509
|
+
def during_init(client):
|
|
510
|
+
feature = FakeFeature("SiLAService", client)
|
|
511
|
+
command = FakeUnobservable(
|
|
512
|
+
feature, FakeCommandDef("GetFeatureDefinition"), log=sink.log, result="<feature/>"
|
|
513
|
+
)
|
|
514
|
+
results.append(command())
|
|
515
|
+
|
|
516
|
+
return FakeClient("127.0.0.1", 50057, insecure=True, log=sink.log, during_init=during_init)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_what_a_client_fetches_to_build_itself_is_not_a_unit(sink):
|
|
520
|
+
"""Half of a real run's units were these, and a gRPC instrumentation would record the same
|
|
521
|
+
RPCs under the connection -- so they are left to it. The connection still is a unit, and
|
|
522
|
+
what they cost is most of it."""
|
|
523
|
+
results: list = []
|
|
524
|
+
|
|
525
|
+
_client_that_fetches(sink, results)
|
|
526
|
+
|
|
527
|
+
assert results == ["<feature/>"], "the fetch itself still happens"
|
|
528
|
+
assert [handle.name for handle in sink.handles] == [SPAN_CONNECT]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def test_a_command_after_the_client_is_built_is_still_a_unit(sink):
|
|
533
|
+
"""The suppression is a stretch of time, not a name: what the operation itself asks the
|
|
534
|
+
instrument to do is measured as it always was."""
|
|
535
|
+
results: list = []
|
|
536
|
+
client = _client_that_fetches(sink, results)
|
|
537
|
+
feature = FakeFeature("SealerControl", client)
|
|
538
|
+
command = FakeUnobservable(feature, FakeCommandDef("Seal", SEAL_FQI), log=sink.log, result="ok")
|
|
539
|
+
|
|
540
|
+
assert command() == "ok"
|
|
541
|
+
|
|
542
|
+
assert [handle.name for handle in sink.handles] == [SPAN_CONNECT, SEAL]
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""The `Sink` that turns `labcode.sila2_instrument`'s measured units into OpenTelemetry spans.
|
|
2
|
-
|
|
3
|
-
The instrumentation decides *what* is worth measuring; this decides *how it is recorded*. Swapping
|
|
4
|
-
the recording backend means rewriting this file -- and leaving the instrumentation, where the
|
|
5
|
-
difficult part lives (which calls to intervene in, how an observable command's real extent is
|
|
6
|
-
observed), untouched.
|
|
7
|
-
|
|
8
|
-
**It configures no provider**, only `trace.get_tracer`. Choosing an exporter and the resource
|
|
9
|
-
attributes is the application's job (`labcode.otel` for a run; a UI that drives instruments
|
|
10
|
-
directly does it for itself). That is the OpenTelemetry convention, and it has a useful
|
|
11
|
-
consequence: with no provider configured the whole thing is a no-op, so a child process that is
|
|
12
|
-
not recording needs no flag of its own to check.
|
|
13
|
-
|
|
14
|
-
Spans are started **without becoming the current span** (`start_span`, not
|
|
15
|
-
`start_as_current_span`). An observable command's span stays open until its responses are
|
|
16
|
-
collected, and must not adopt the script code that runs meanwhile; wrapping the windows where an
|
|
17
|
-
RPC actually runs is the instrumentation's job, through `active`.
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
from collections.abc import Mapping
|
|
23
|
-
from contextlib import AbstractContextManager
|
|
24
|
-
from typing import Any
|
|
25
|
-
|
|
26
|
-
from opentelemetry import trace
|
|
27
|
-
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
|
|
28
|
-
|
|
29
|
-
from labcode.sila2_instrument import KIND_CLIENT
|
|
30
|
-
|
|
31
|
-
#: Measured-unit kind -> span kind. A call to an instrument is a CLIENT span; left unset it would
|
|
32
|
-
#: be INTERNAL, and the record would no longer say that it left this process.
|
|
33
|
-
_KINDS = {KIND_CLIENT: SpanKind.CLIENT}
|
|
34
|
-
|
|
35
|
-
#: Where a failure's kind goes (the OpenTelemetry convention).
|
|
36
|
-
ATTR_ERROR_TYPE = "error.type"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class OtelSink:
|
|
40
|
-
"""`labcode.sila2_instrument.Sink`, recorded as OpenTelemetry spans."""
|
|
41
|
-
|
|
42
|
-
def __init__(self, tracer: Any | None = None) -> None:
|
|
43
|
-
# Fetching the tracer eagerly is safe: the API returns one that does nothing until a
|
|
44
|
-
# provider is configured, and follows the provider once one is.
|
|
45
|
-
self._tracer = tracer if tracer is not None else trace.get_tracer("labcode.sila2")
|
|
46
|
-
|
|
47
|
-
def start(self, name: str, *, kind: str, attributes: Mapping[str, Any]) -> Span:
|
|
48
|
-
return self._tracer.start_span(
|
|
49
|
-
name, kind=_KINDS.get(kind, SpanKind.INTERNAL), attributes=dict(attributes)
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
def update(self, handle: Span, attributes: Mapping[str, Any]) -> None:
|
|
53
|
-
for key, value in attributes.items():
|
|
54
|
-
handle.set_attribute(key, value)
|
|
55
|
-
|
|
56
|
-
def end(
|
|
57
|
-
self, handle: Span, *, error_type: str | None = None, message: str | None = None
|
|
58
|
-
) -> None:
|
|
59
|
-
if error_type is not None:
|
|
60
|
-
handle.set_attribute(ATTR_ERROR_TYPE, error_type)
|
|
61
|
-
handle.set_status(Status(StatusCode.ERROR, message))
|
|
62
|
-
handle.end()
|
|
63
|
-
|
|
64
|
-
def active(self, handle: Span) -> AbstractContextManager[Any]:
|
|
65
|
-
# `end_on_exit=False`: this window only lends the current context. How long the span lives
|
|
66
|
-
# is the instrumentation's to decide -- an observable command opens two windows before it
|
|
67
|
-
# ends.
|
|
68
|
-
return trace.use_span(handle, end_on_exit=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|