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.
Files changed (83) hide show
  1. {labcode-0.1.7 → labcode-0.1.8}/PKG-INFO +21 -5
  2. {labcode-0.1.7 → labcode-0.1.8}/README.md +17 -4
  3. {labcode-0.1.7 → labcode-0.1.8}/labcode/otel.py +8 -4
  4. labcode-0.1.8/labcode/otel_sila2.py +182 -0
  5. {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2_instrument.py +49 -1
  6. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/PKG-INFO +21 -5
  7. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/SOURCES.txt +1 -0
  8. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/requires.txt +3 -0
  9. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/scm_file_list.json +1 -0
  10. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/scm_version.json +2 -2
  11. {labcode-0.1.7 → labcode-0.1.8}/pyproject.toml +23 -2
  12. {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel_child.py +40 -0
  13. labcode-0.1.8/tests/test_otel_grpc.py +153 -0
  14. {labcode-0.1.7 → labcode-0.1.8}/tests/test_sila2_instrument.py +52 -2
  15. labcode-0.1.7/labcode/otel_sila2.py +0 -68
  16. {labcode-0.1.7 → labcode-0.1.8}/.github/workflows/ci.yml +0 -0
  17. {labcode-0.1.7 → labcode-0.1.8}/.github/workflows/publish.yml +0 -0
  18. {labcode-0.1.7 → labcode-0.1.8}/.gitignore +0 -0
  19. {labcode-0.1.7 → labcode-0.1.8}/LICENSE +0 -0
  20. {labcode-0.1.7 → labcode-0.1.8}/MANIFEST.in +0 -0
  21. {labcode-0.1.7 → labcode-0.1.8}/SPECIFICATIONS.md +0 -0
  22. {labcode-0.1.7 → labcode-0.1.8}/examples/README.md +0 -0
  23. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.boundary.yaml +0 -0
  24. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.observation.yaml +0 -0
  25. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.plan.yaml +0 -0
  26. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/plate_line.svg +0 -0
  27. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  28. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  29. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  30. {labcode-0.1.7 → labcode-0.1.8}/examples/outputs/sila2_plate_cycle.svg +0 -0
  31. {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.boundary.yaml +0 -0
  32. {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.env.yaml +0 -0
  33. {labcode-0.1.7 → labcode-0.1.8}/examples/plate_line.workflow.yaml +0 -0
  34. {labcode-0.1.7 → labcode-0.1.8}/examples/render_plate_line.py +0 -0
  35. {labcode-0.1.7 → labcode-0.1.8}/examples/render_sila2_plate_cycle.py +0 -0
  36. {labcode-0.1.7 → labcode-0.1.8}/examples/run_all_sila2_examples.py +0 -0
  37. {labcode-0.1.7 → labcode-0.1.8}/examples/run_sila2_plate_cycle.py +0 -0
  38. {labcode-0.1.7 → labcode-0.1.8}/examples/run_sila2_seal.py +0 -0
  39. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  40. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  41. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
  42. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.boundary.yaml +0 -0
  43. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.env.yaml +0 -0
  44. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.workflow.yaml +0 -0
  45. {labcode-0.1.7 → labcode-0.1.8}/examples/sila2_seal.wrapped.env.yaml +0 -0
  46. {labcode-0.1.7 → labcode-0.1.8}/labcode/__init__.py +0 -0
  47. {labcode-0.1.7 → labcode-0.1.8}/labcode/__main__.py +0 -0
  48. {labcode-0.1.7 → labcode-0.1.8}/labcode/_child.py +0 -0
  49. {labcode-0.1.7 → labcode-0.1.8}/labcode/backend.py +0 -0
  50. {labcode-0.1.7 → labcode-0.1.8}/labcode/cli.py +0 -0
  51. {labcode-0.1.7 → labcode-0.1.8}/labcode/dialect.py +0 -0
  52. {labcode-0.1.7 → labcode-0.1.8}/labcode/extension.py +0 -0
  53. {labcode-0.1.7 → labcode-0.1.8}/labcode/idgen.py +0 -0
  54. {labcode-0.1.7 → labcode-0.1.8}/labcode/objectid.py +0 -0
  55. {labcode-0.1.7 → labcode-0.1.8}/labcode/probe.py +0 -0
  56. {labcode-0.1.7 → labcode-0.1.8}/labcode/py.typed +0 -0
  57. {labcode-0.1.7 → labcode-0.1.8}/labcode/record.py +0 -0
  58. {labcode-0.1.7 → labcode-0.1.8}/labcode/run_cli.py +0 -0
  59. {labcode-0.1.7 → labcode-0.1.8}/labcode/runner.py +0 -0
  60. {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2.py +0 -0
  61. {labcode-0.1.7 → labcode-0.1.8}/labcode/sila2_commands.py +0 -0
  62. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/dependency_links.txt +0 -0
  63. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/entry_points.txt +0 -0
  64. {labcode-0.1.7 → labcode-0.1.8}/labcode.egg-info/top_level.txt +0 -0
  65. {labcode-0.1.7 → labcode-0.1.8}/setup.cfg +0 -0
  66. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/device_script.env.yaml +0 -0
  67. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/device_script.workflow.yaml +0 -0
  68. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/reroute_device.env.yaml +0 -0
  69. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  70. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/transport.env.yaml +0 -0
  71. {labcode-0.1.7 → labcode-0.1.8}/tests/fixtures/transport.workflow.yaml +0 -0
  72. {labcode-0.1.7 → labcode-0.1.8}/tests/test_backend.py +0 -0
  73. {labcode-0.1.7 → labcode-0.1.8}/tests/test_cli.py +0 -0
  74. {labcode-0.1.7 → labcode-0.1.8}/tests/test_dialect.py +0 -0
  75. {labcode-0.1.7 → labcode-0.1.8}/tests/test_objectid.py +0 -0
  76. {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel.py +0 -0
  77. {labcode-0.1.7 → labcode-0.1.8}/tests/test_otel_sila2.py +0 -0
  78. {labcode-0.1.7 → labcode-0.1.8}/tests/test_probe.py +0 -0
  79. {labcode-0.1.7 → labcode-0.1.8}/tests/test_record.py +0 -0
  80. {labcode-0.1.7 → labcode-0.1.8}/tests/test_recording.py +0 -0
  81. {labcode-0.1.7 → labcode-0.1.8}/tests/test_run_cli.py +0 -0
  82. {labcode-0.1.7 → labcode-0.1.8}/tests/test_sila2.py +0 -0
  83. {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.7
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 a span per SiLA2 connection and
114
- command measured inside the process that issued it. Off by default; the extra is
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 starts:
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 a span per SiLA2 connection and
73
- command measured inside the process that issued it. Off by default; the extra is
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 starts:
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` via
6
- `labcode.otel_sila2`). Replacing it means writing one more module like this one and adding two
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
- with _active(handle):
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.7
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 a span per SiLA2 connection and
114
- command measured inside the process that issued it. Off by default; the extra is
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 starts:
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
@@ -63,6 +63,7 @@ tests/test_dialect.py
63
63
  tests/test_objectid.py
64
64
  tests/test_otel.py
65
65
  tests/test_otel_child.py
66
+ tests/test_otel_grpc.py
66
67
  tests/test_otel_sila2.py
67
68
  tests/test_probe.py
68
69
  tests/test_record.py
@@ -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
@@ -63,6 +63,7 @@
63
63
  "tests/test_objectid.py",
64
64
  "tests/test_otel.py",
65
65
  "tests/test_otel_child.py",
66
+ "tests/test_otel_grpc.py",
66
67
  "tests/test_otel_sila2.py",
67
68
  "tests/test_probe.py",
68
69
  "tests/test_record.py",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.1.7",
2
+ "tag": "0.1.8",
3
3
  "distance": 0,
4
- "node": "g5ac30ea05ab94fb91cc31c75db1c96a11d51eb19",
4
+ "node": "g6afb1daec9a83355371efb2f4a4151b275e4fd73",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-08-21"
@@ -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
- test = ["pytest>=7.0", "opentelemetry-sdk>=1.20"]
54
- dev = ["pytest>=7.0", "ruff>=0.16", "mypy>=1.11", "opentelemetry-sdk>=1.20"]
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
- def __init__(self, address, port, *, insecure=False, log=None, fail=None) -> None:
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