graftcode-instrumentation 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. graftcode_instrumentation-1.0.0/PKG-INFO +105 -0
  2. graftcode_instrumentation-1.0.0/README.md +90 -0
  3. graftcode_instrumentation-1.0.0/graftcode_instrumentation/BodyCapture.py +192 -0
  4. graftcode_instrumentation-1.0.0/graftcode_instrumentation/CallSpan.py +227 -0
  5. graftcode_instrumentation-1.0.0/graftcode_instrumentation/GraftcodeInstrumentation.py +197 -0
  6. graftcode_instrumentation-1.0.0/graftcode_instrumentation/GraftcodeTelemetrySettings.py +67 -0
  7. graftcode_instrumentation-1.0.0/graftcode_instrumentation/LibraryCatalog.py +195 -0
  8. graftcode_instrumentation-1.0.0/graftcode_instrumentation/OperationName.py +146 -0
  9. graftcode_instrumentation-1.0.0/graftcode_instrumentation/__init__.py +12 -0
  10. graftcode_instrumentation-1.0.0/graftcode_instrumentation.egg-info/PKG-INFO +105 -0
  11. graftcode_instrumentation-1.0.0/graftcode_instrumentation.egg-info/SOURCES.txt +19 -0
  12. graftcode_instrumentation-1.0.0/graftcode_instrumentation.egg-info/dependency_links.txt +1 -0
  13. graftcode_instrumentation-1.0.0/graftcode_instrumentation.egg-info/requires.txt +7 -0
  14. graftcode_instrumentation-1.0.0/graftcode_instrumentation.egg-info/top_level.txt +1 -0
  15. graftcode_instrumentation-1.0.0/pyproject.toml +30 -0
  16. graftcode_instrumentation-1.0.0/setup.cfg +4 -0
  17. graftcode_instrumentation-1.0.0/tests/test_BodyCapture.py +109 -0
  18. graftcode_instrumentation-1.0.0/tests/test_CallSpan.py +220 -0
  19. graftcode_instrumentation-1.0.0/tests/test_GraftcodeInstrumentation.py +120 -0
  20. graftcode_instrumentation-1.0.0/tests/test_GraftcodeTelemetrySettings.py +14 -0
  21. graftcode_instrumentation-1.0.0/tests/test_OperationName.py +45 -0
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: graftcode-instrumentation
3
+ Version: 1.0.0
4
+ Summary: OpenTelemetry instrumentation for Graftcode / Hypertube calls in Python
5
+ Author-email: "Graftcode S.A." <support@graftcode.com>
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: opentelemetry-api>=1.20
10
+ Requires-Dist: hypertube-python-sdk>=3.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
13
+ Requires-Dist: graftcode-context>=1.0.3; extra == "dev"
14
+ Requires-Dist: pytest; extra == "dev"
15
+
16
+ # graftcode-instrumentation (Python)
17
+
18
+ OpenTelemetry instrumentation for Graftcode (Hypertube) calls in Python. It produces a caller span and a callee span per call, propagates W3C trace context, and forwards Hypertube log lines. It is a port of the .NET (`Graftcode.Instrumentation`) and Java (`com.graftcode.instrumentation`) packages. Class names, span names, attribute keys and header names are the same in all three.
19
+
20
+ Hypertube exposes a broker (`HypertubeBroker`, in `hypertube.utils.abstractions`) and a log listener registry (`HypertubeLogger`, in `hypertube.utils.logging`). Nothing is monkey-patched. `GraftcodeInstrumentation.attach(...)` registers one object as an invocation listener, a handling listener and a log listener; `close()` removes all three. The Hypertube SDK itself has no OpenTelemetry dependency.
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ import logging
26
+ from opentelemetry.sdk.trace import TracerProvider
27
+ from graftcode_instrumentation import GraftcodeInstrumentation, LOGGER_NAME
28
+
29
+ provider = TracerProvider()
30
+ instrumentation = GraftcodeInstrumentation.attach(provider, logging.getLogger(LOGGER_NAME))
31
+ ...
32
+ instrumentation.close()
33
+ ```
34
+
35
+ | Argument | Meaning |
36
+ |----------|---------|
37
+ | `tracer_provider` | Provider used to create the `Graftcode` tracer. Defaults to the global provider. |
38
+ | `logger` | A standard-library `logging.Logger`. Plays the role of `ILogger` in .NET or the OpenTelemetry logs bridge in Java. If it is omitted, Hypertube log lines are recorded as events on the current span. |
39
+
40
+ Names: `INSTRUMENTATION_NAME = "Graftcode"` (tracer name), `LOGGER_NAME = "Hypertube"` (logger name used by the App Insights connector).
41
+
42
+ ## Settings (`GraftcodeTelemetrySettings`)
43
+
44
+ Static getters and setters, as in Java:
45
+
46
+ | Setting | Environment variable | Default |
47
+ |---------|----------------------|---------|
48
+ | `is_auto_inject_enabled` / `set_auto_inject_enabled` | `HYPERTUBE_TELEMETRY_AUTO_INJECT` | `true` |
49
+ | `is_body_capture_enabled` / `set_body_capture_enabled` | `HYPERTUBE_TELEMETRY_CAPTURE_BODIES` | `false` |
50
+ | `get_body_max_characters` / `set_body_max_characters` | `HYPERTUBE_TELEMETRY_BODY_MAX_CHARS` | `8192`, clamped to 256–32768 |
51
+
52
+ ## Span model (`CallSpan`)
53
+
54
+ - **Caller** (`on_execute`): span `Graft <library> [version] <channel>`, kind `INTERNAL`. Attributes are `graft.dependency.type = Graft`, `graft.dependency.target`, `hypertube.runtime`, `hypertube.command_type`, `hypertube.connection_type`, `hypertube.operation`, and `hypertube.target` (with user info stripped).
55
+ - **Callee** (`on_process`): span named after the operation, for example `TestGgPythonLib.RandomService.RandomService.fetch`, kind `INTERNAL`.
56
+ - No span is opened for `PassByValue`, `RegisterForUpdate`, `ValueForUpdate`, `InvokeDelegate`, or any operation ending in `TelemetrySetup.Initialize`.
57
+ - When a call fails, the span gets `exception.type`, a redacted `exception.message`, and status `ERROR`.
58
+ - The span becomes current while the call runs (`opentelemetry.context.attach`) and is detached when the call completes. Code inside the called method therefore sees the callee span as its parent.
59
+
60
+ ## Trace context
61
+
62
+ - **Outgoing:** the caller copies the thread-local connection headers without `tracestate`, injects W3C `traceparent`/`tracestate` plus `traceid`, and restores the previous headers when the call completes. A `traceparent` that the application set on the connection is never overwritten.
63
+ - **Incoming:** W3C headers from the `ContextMetadata` command are preferred. If only a `traceid` header arrives and it differs from the current trace, the callee joins that trace under a synthetic remote parent.
64
+ - **Called side:** on the called side, `ContextMetadata` is handled through `graftcode.context.request_context.RequestContext`, so the hosted library needs `graftcode-context` installed.
65
+
66
+ ## Operation names (`OperationName`)
67
+
68
+ | Command | Name |
69
+ |---------|------|
70
+ | Member access (static/instance method, field, delegate) | `Type.member` |
71
+ | `CreateClassInstance` | `Type.ctor` |
72
+ | `InvokeGlobalFunction` / `GetGlobalField` | the function or field name |
73
+ | Anything else | `hypertube.<CommandType>` |
74
+
75
+ ## Library catalog (`LibraryCatalog`)
76
+
77
+ `record` remembers the libraries loaded on a connection (`LoadLibrary`). `describe_target` formats `<library> [version] <channel>`, where the channel is the lowercased connection type. The library name is chosen in this order:
78
+
79
+ 1. The `LoadLibrary` file stem.
80
+ 2. The longest recorded library that prefixes the target type.
81
+ 3. The root namespace of the target type.
82
+
83
+ Versions come from the jar manifest (`Implementation-Version`) or from `importlib.metadata` for Python packages. Without a library the target is `<runtime> <channel>`.
84
+
85
+ ## Body capture (`BodyCapture`)
86
+
87
+ Body capture is off by default. When it is enabled, request and response commands are rendered as JSON-like text (`{"runtime":..,"type":..,"payload":[..]}`) on `hypertube.request.body` / `hypertube.response.body`:
88
+
89
+ - Sensitive keys, connection strings, bearer tokens and JWTs are redacted.
90
+ - Output is cut at the character budget; a cut body gets the `"__truncated__"` marker and the `.truncated` attribute.
91
+
92
+ ## Logs
93
+
94
+ Hypertube writes its own console lines asynchronously (`AsyncLogSink`, writer thread `hypertube-log-writer`). Independently of that, the instrumentation receives every rendered line together with its template properties:
95
+
96
+ - **With a logger:** the line is forwarded at the matching level (`Trace`→5, `Debug`→10, `Info`→20, `Warning`→30, `Error`→40). Template properties are passed as `extra`, and the exception as `exc_info`. Names that clash with `LogRecord` attributes get a trailing `_`.
97
+ - **Without a logger:** the line becomes a span event carrying `log.level`. Exceptions become an `exception` event with redacted `exception.message` / `exception.stacktrace`.
98
+
99
+ ## Tests
100
+
101
+ ```powershell
102
+ $env:PYTHONPATH = "<HYPERTUBE>\src\python"
103
+ python -m pip install -e .[dev]
104
+ python -m pytest
105
+ ```
@@ -0,0 +1,90 @@
1
+ # graftcode-instrumentation (Python)
2
+
3
+ OpenTelemetry instrumentation for Graftcode (Hypertube) calls in Python. It produces a caller span and a callee span per call, propagates W3C trace context, and forwards Hypertube log lines. It is a port of the .NET (`Graftcode.Instrumentation`) and Java (`com.graftcode.instrumentation`) packages. Class names, span names, attribute keys and header names are the same in all three.
4
+
5
+ Hypertube exposes a broker (`HypertubeBroker`, in `hypertube.utils.abstractions`) and a log listener registry (`HypertubeLogger`, in `hypertube.utils.logging`). Nothing is monkey-patched. `GraftcodeInstrumentation.attach(...)` registers one object as an invocation listener, a handling listener and a log listener; `close()` removes all three. The Hypertube SDK itself has no OpenTelemetry dependency.
6
+
7
+ ## Usage
8
+
9
+ ```python
10
+ import logging
11
+ from opentelemetry.sdk.trace import TracerProvider
12
+ from graftcode_instrumentation import GraftcodeInstrumentation, LOGGER_NAME
13
+
14
+ provider = TracerProvider()
15
+ instrumentation = GraftcodeInstrumentation.attach(provider, logging.getLogger(LOGGER_NAME))
16
+ ...
17
+ instrumentation.close()
18
+ ```
19
+
20
+ | Argument | Meaning |
21
+ |----------|---------|
22
+ | `tracer_provider` | Provider used to create the `Graftcode` tracer. Defaults to the global provider. |
23
+ | `logger` | A standard-library `logging.Logger`. Plays the role of `ILogger` in .NET or the OpenTelemetry logs bridge in Java. If it is omitted, Hypertube log lines are recorded as events on the current span. |
24
+
25
+ Names: `INSTRUMENTATION_NAME = "Graftcode"` (tracer name), `LOGGER_NAME = "Hypertube"` (logger name used by the App Insights connector).
26
+
27
+ ## Settings (`GraftcodeTelemetrySettings`)
28
+
29
+ Static getters and setters, as in Java:
30
+
31
+ | Setting | Environment variable | Default |
32
+ |---------|----------------------|---------|
33
+ | `is_auto_inject_enabled` / `set_auto_inject_enabled` | `HYPERTUBE_TELEMETRY_AUTO_INJECT` | `true` |
34
+ | `is_body_capture_enabled` / `set_body_capture_enabled` | `HYPERTUBE_TELEMETRY_CAPTURE_BODIES` | `false` |
35
+ | `get_body_max_characters` / `set_body_max_characters` | `HYPERTUBE_TELEMETRY_BODY_MAX_CHARS` | `8192`, clamped to 256–32768 |
36
+
37
+ ## Span model (`CallSpan`)
38
+
39
+ - **Caller** (`on_execute`): span `Graft <library> [version] <channel>`, kind `INTERNAL`. Attributes are `graft.dependency.type = Graft`, `graft.dependency.target`, `hypertube.runtime`, `hypertube.command_type`, `hypertube.connection_type`, `hypertube.operation`, and `hypertube.target` (with user info stripped).
40
+ - **Callee** (`on_process`): span named after the operation, for example `TestGgPythonLib.RandomService.RandomService.fetch`, kind `INTERNAL`.
41
+ - No span is opened for `PassByValue`, `RegisterForUpdate`, `ValueForUpdate`, `InvokeDelegate`, or any operation ending in `TelemetrySetup.Initialize`.
42
+ - When a call fails, the span gets `exception.type`, a redacted `exception.message`, and status `ERROR`.
43
+ - The span becomes current while the call runs (`opentelemetry.context.attach`) and is detached when the call completes. Code inside the called method therefore sees the callee span as its parent.
44
+
45
+ ## Trace context
46
+
47
+ - **Outgoing:** the caller copies the thread-local connection headers without `tracestate`, injects W3C `traceparent`/`tracestate` plus `traceid`, and restores the previous headers when the call completes. A `traceparent` that the application set on the connection is never overwritten.
48
+ - **Incoming:** W3C headers from the `ContextMetadata` command are preferred. If only a `traceid` header arrives and it differs from the current trace, the callee joins that trace under a synthetic remote parent.
49
+ - **Called side:** on the called side, `ContextMetadata` is handled through `graftcode.context.request_context.RequestContext`, so the hosted library needs `graftcode-context` installed.
50
+
51
+ ## Operation names (`OperationName`)
52
+
53
+ | Command | Name |
54
+ |---------|------|
55
+ | Member access (static/instance method, field, delegate) | `Type.member` |
56
+ | `CreateClassInstance` | `Type.ctor` |
57
+ | `InvokeGlobalFunction` / `GetGlobalField` | the function or field name |
58
+ | Anything else | `hypertube.<CommandType>` |
59
+
60
+ ## Library catalog (`LibraryCatalog`)
61
+
62
+ `record` remembers the libraries loaded on a connection (`LoadLibrary`). `describe_target` formats `<library> [version] <channel>`, where the channel is the lowercased connection type. The library name is chosen in this order:
63
+
64
+ 1. The `LoadLibrary` file stem.
65
+ 2. The longest recorded library that prefixes the target type.
66
+ 3. The root namespace of the target type.
67
+
68
+ Versions come from the jar manifest (`Implementation-Version`) or from `importlib.metadata` for Python packages. Without a library the target is `<runtime> <channel>`.
69
+
70
+ ## Body capture (`BodyCapture`)
71
+
72
+ Body capture is off by default. When it is enabled, request and response commands are rendered as JSON-like text (`{"runtime":..,"type":..,"payload":[..]}`) on `hypertube.request.body` / `hypertube.response.body`:
73
+
74
+ - Sensitive keys, connection strings, bearer tokens and JWTs are redacted.
75
+ - Output is cut at the character budget; a cut body gets the `"__truncated__"` marker and the `.truncated` attribute.
76
+
77
+ ## Logs
78
+
79
+ Hypertube writes its own console lines asynchronously (`AsyncLogSink`, writer thread `hypertube-log-writer`). Independently of that, the instrumentation receives every rendered line together with its template properties:
80
+
81
+ - **With a logger:** the line is forwarded at the matching level (`Trace`→5, `Debug`→10, `Info`→20, `Warning`→30, `Error`→40). Template properties are passed as `extra`, and the exception as `exc_info`. Names that clash with `LogRecord` attributes get a trailing `_`.
82
+ - **Without a logger:** the line becomes a span event carrying `log.level`. Exceptions become an `exception` event with redacted `exception.message` / `exception.stacktrace`.
83
+
84
+ ## Tests
85
+
86
+ ```powershell
87
+ $env:PYTHONPATH = "<HYPERTUBE>\src\python"
88
+ python -m pip install -e .[dev]
89
+ python -m pytest
90
+ ```
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from hypertube.utils.Command import Command
6
+ from hypertube.utils.logging.LogsRedactor import LogsRedactor
7
+
8
+ from graftcode_instrumentation.OperationName import OperationName
9
+
10
+
11
+ class BodyCaptureResult:
12
+ __slots__ = ("body", "truncated")
13
+
14
+ def __init__(self, body: Optional[str], truncated: bool) -> None:
15
+ self.body = body
16
+ self.truncated = truncated
17
+
18
+
19
+ class BodyCapture:
20
+ TRUNCATION_MARKER = '"__truncated__"'
21
+
22
+ _MAX_DEPTH = 24
23
+ _CLOSING_COST_PER_LEVEL = 18
24
+ _SCALAR_RESERVE = 32
25
+
26
+ @staticmethod
27
+ def render(command: Optional[Command], max_characters: Optional[int]) -> Optional[BodyCaptureResult]:
28
+ if command is None:
29
+ return None
30
+
31
+ truncated: list[bool] = [False]
32
+ builder = _Builder()
33
+ BodyCapture._write_command(builder, command, 0, max_characters, truncated)
34
+
35
+ return BodyCaptureResult(LogsRedactor.redact_body(builder.to_string()), truncated[0])
36
+
37
+ @staticmethod
38
+ def _write_command(
39
+ builder: _Builder,
40
+ command: Command,
41
+ depth: int,
42
+ budget: Optional[int],
43
+ truncated: list[bool],
44
+ ) -> bool:
45
+ builder.append('{"runtime":"')
46
+ builder.append(str(command.runtime_name))
47
+ builder.append('","type":"')
48
+ builder.append(command.command_type.name)
49
+ builder.append('","payload":[')
50
+
51
+ payload = command.payload
52
+ within_budget = True
53
+ has_content = False
54
+ if budget is not None:
55
+ local_budget = max(
56
+ 0,
57
+ budget - ((depth + 1) * BodyCapture._CLOSING_COST_PER_LEVEL) - BodyCapture._SCALAR_RESERVE,
58
+ )
59
+ else:
60
+ local_budget = 2 ** 30
61
+
62
+ for i, item in enumerate(payload):
63
+ if builder.length >= local_budget:
64
+ truncated[0] = True
65
+ within_budget = False
66
+ break
67
+
68
+ if has_content:
69
+ builder.append(",")
70
+
71
+ follows_sensitive_key = (
72
+ i > 0
73
+ and isinstance(payload[i - 1], str)
74
+ and LogsRedactor.is_sensitive_key(payload[i - 1]))
75
+
76
+ if not BodyCapture._write_value(
77
+ builder, item, depth + 1, budget, local_budget, follows_sensitive_key, truncated):
78
+ has_content = True
79
+ within_budget = False
80
+ break
81
+
82
+ has_content = True
83
+
84
+ if not within_budget:
85
+ if has_content:
86
+ builder.append(",")
87
+
88
+ builder.append(BodyCapture.TRUNCATION_MARKER)
89
+
90
+ builder.append("]}")
91
+ return within_budget
92
+
93
+ @staticmethod
94
+ def _write_value(
95
+ builder: _Builder,
96
+ value: Any,
97
+ depth: int,
98
+ budget: Optional[int],
99
+ local_budget: int,
100
+ force_redact: bool,
101
+ truncated: list[bool],
102
+ ) -> bool:
103
+ if value is None:
104
+ builder.append("null")
105
+ return True
106
+
107
+ if depth >= BodyCapture._MAX_DEPTH:
108
+ truncated[0] = True
109
+ builder.append(BodyCapture.TRUNCATION_MARKER)
110
+ return True
111
+
112
+ if isinstance(value, Command):
113
+ return BodyCapture._write_command(builder, value, depth, budget, truncated)
114
+
115
+ if isinstance(value, str):
116
+ return BodyCapture._write_string(
117
+ builder,
118
+ LogsRedactor.PLACEHOLDER if force_redact else LogsRedactor.redact_value(value),
119
+ local_budget,
120
+ truncated)
121
+
122
+ if isinstance(value, bool):
123
+ builder.append("true" if value else "false")
124
+ return True
125
+
126
+ if isinstance(value, (int, float)):
127
+ builder.append(repr(value))
128
+ return True
129
+
130
+ if isinstance(value, (bytes, bytearray)):
131
+ builder.append('"[bytes:' + str(len(value)) + ']"')
132
+ return True
133
+
134
+ return BodyCapture._write_string(
135
+ builder,
136
+ LogsRedactor.PLACEHOLDER if force_redact else LogsRedactor.redact_value(BodyCapture._format(value)),
137
+ local_budget,
138
+ truncated)
139
+
140
+ @staticmethod
141
+ def _format(value: Any) -> str:
142
+ try:
143
+ return str(value)
144
+ except Exception:
145
+ return "[unrenderable:" + OperationName._full_name(type(value)) + "]"
146
+
147
+ @staticmethod
148
+ def _write_string(builder: _Builder, value: Optional[str], budget: int, truncated: List[bool]) -> bool:
149
+ builder.append('"')
150
+
151
+ if value is None:
152
+ builder.append('"')
153
+ return True
154
+
155
+ for character in value:
156
+ if builder.length >= budget:
157
+ truncated[0] = True
158
+ builder.append('"')
159
+ return False
160
+
161
+ if character == '"':
162
+ builder.append('\\"')
163
+ elif character == "\\":
164
+ builder.append("\\\\")
165
+ elif character == "\n":
166
+ builder.append("\\n")
167
+ elif character == "\r":
168
+ builder.append("\\r")
169
+ elif character == "\t":
170
+ builder.append("\\t")
171
+ elif character < " ":
172
+ builder.append("\\u%04x" % ord(character))
173
+ else:
174
+ builder.append(character)
175
+
176
+ builder.append('"')
177
+ return True
178
+
179
+
180
+ class _Builder:
181
+ __slots__ = ("_parts", "length")
182
+
183
+ def __init__(self) -> None:
184
+ self._parts: list[str] = []
185
+ self.length = 0
186
+
187
+ def append(self, text: str) -> None:
188
+ self._parts.append(text)
189
+ self.length += len(text)
190
+
191
+ def to_string(self) -> str:
192
+ return "".join(self._parts)
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from typing import Any, Mapping, Optional
5
+
6
+ from opentelemetry import context as otel_context
7
+ from opentelemetry import trace
8
+ from opentelemetry.context import Context
9
+ from opentelemetry.propagators.textmap import Getter
10
+ from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, SpanKind, Status, StatusCode, TraceFlags, Tracer
11
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
12
+
13
+ from hypertube.utils.Command import Command
14
+ from hypertube.utils.CommandType import CommandType
15
+ from hypertube.utils.connectionData.IConnectionData import IConnectionData
16
+ from hypertube.utils.logging.LogsRedactor import LogsRedactor
17
+
18
+ from graftcode_instrumentation.BodyCapture import BodyCapture
19
+ from graftcode_instrumentation.GraftcodeTelemetrySettings import GraftcodeTelemetrySettings
20
+ from graftcode_instrumentation.LibraryCatalog import LibraryCatalog
21
+ from graftcode_instrumentation.OperationName import OperationName
22
+
23
+ _SKIPPED_COMMANDS = (
24
+ CommandType.PassByValue,
25
+ CommandType.RegisterForUpdate,
26
+ CommandType.ValueForUpdate,
27
+ CommandType.InvokeDelegate,
28
+ )
29
+
30
+
31
+ def _header(headers: Mapping[Any, Any], key: str) -> Optional[Any]:
32
+ value = headers.get(key)
33
+ if value is not None:
34
+ return value
35
+
36
+ for name, candidate in headers.items():
37
+ if key.lower() == str(name).lower():
38
+ return candidate
39
+
40
+ return None
41
+
42
+
43
+ class _HeaderGetter(Getter):
44
+
45
+ def get(self, carrier: Any, key: str) -> Optional[list[str]]:
46
+ value = _header(carrier, key)
47
+ return None if value is None else [value]
48
+
49
+ def keys(self, carrier: Any) -> list[Any]:
50
+ return list(carrier.keys())
51
+
52
+
53
+ class CallSpan:
54
+ DEPENDENCY_TYPE = "graft.dependency.type"
55
+ DEPENDENCY_TARGET = "graft.dependency.target"
56
+ BRIDGE_DEPENDENCY_TYPE = "Graft"
57
+ REQUEST_BODY = "hypertube.request.body"
58
+ RESPONSE_BODY = "hypertube.response.body"
59
+ REQUEST_BODY_TRUNCATED = "hypertube.request.body.truncated"
60
+ RESPONSE_BODY_TRUNCATED = "hypertube.response.body.truncated"
61
+ TRACE_PARENT_HEADER = "traceparent"
62
+ TRACE_STATE_HEADER = "tracestate"
63
+ TRACE_ID_HEADER = "traceid"
64
+
65
+ _PROPAGATOR = TraceContextTextMapPropagator()
66
+ _HEADER_GETTER = _HeaderGetter()
67
+
68
+ def __init__(self, span: Span) -> None:
69
+ self.span = span
70
+ self._token: Any = otel_context.attach(trace.set_span_in_context(span))
71
+ self._injected_into: Optional[IConnectionData] = None
72
+ self._previous_headers: Optional[Mapping[str, str]] = None
73
+ self._completed = False
74
+
75
+ @staticmethod
76
+ def start_invoke(tracer: Tracer, command: Command, connection_data: IConnectionData) -> Optional[CallSpan]:
77
+ operation = CallSpan._resolve_operation(command)
78
+ if operation is None:
79
+ return None
80
+
81
+ LibraryCatalog.record(command, connection_data)
82
+ target = LibraryCatalog.describe_target(command, connection_data)
83
+ span = CallSpan._start(tracer, "Graft " + target, connection_data.get_headers())
84
+
85
+ if span.is_recording():
86
+ span.set_attribute("hypertube.runtime", str(command.runtime_name))
87
+ span.set_attribute("hypertube.command_type", command.command_type.name)
88
+ span.set_attribute("hypertube.connection_type", connection_data.connection_type.name)
89
+ span.set_attribute(CallSpan.DEPENDENCY_TYPE, CallSpan.BRIDGE_DEPENDENCY_TYPE)
90
+ CallSpan._set_if_present(span, CallSpan.DEPENDENCY_TARGET, LogsRedactor.redact_value(target))
91
+ CallSpan._set_if_present(span, "hypertube.operation", LogsRedactor.redact_value(operation))
92
+ CallSpan._set_if_present(
93
+ span,
94
+ "hypertube.target",
95
+ LogsRedactor.redact_value(LogsRedactor.redact_target(connection_data.hostname)),
96
+ )
97
+
98
+ call_span = CallSpan(span)
99
+ call_span._capture(command, CallSpan.REQUEST_BODY, CallSpan.REQUEST_BODY_TRUNCATED)
100
+ if GraftcodeTelemetrySettings.is_auto_inject_enabled():
101
+ call_span._inject_into(connection_data)
102
+
103
+ return call_span
104
+
105
+ @staticmethod
106
+ def start_handle(tracer: Tracer, command: Command, headers: Mapping[str, str]) -> Optional[CallSpan]:
107
+ operation = CallSpan._resolve_operation(command)
108
+ if operation is None:
109
+ return None
110
+
111
+ call_span = CallSpan(CallSpan._start(tracer, operation, headers))
112
+ call_span._capture(command, CallSpan.REQUEST_BODY, CallSpan.REQUEST_BODY_TRUNCATED)
113
+ return call_span
114
+
115
+ def complete(self, response: Any, exception: Optional[BaseException]) -> None:
116
+ if self._completed:
117
+ return
118
+
119
+ self._completed = True
120
+ try:
121
+ if response is not None:
122
+ self._capture(response, CallSpan.RESPONSE_BODY, CallSpan.RESPONSE_BODY_TRUNCATED)
123
+
124
+ if exception is None:
125
+ self.span.set_status(Status(StatusCode.OK))
126
+ else:
127
+ message = LogsRedactor.redact_value(str(exception))
128
+ self.span.set_attribute("exception.type", OperationName._full_name(type(exception)))
129
+ CallSpan._set_if_present(self.span, "exception.message", message)
130
+ self.span.set_status(Status(StatusCode.ERROR, message or ""))
131
+
132
+ if self._injected_into is not None:
133
+ self._injected_into.set_thread_local_headers(self._previous_headers)
134
+ finally:
135
+ try:
136
+ otel_context.detach(self._token)
137
+ finally:
138
+ self.span.end()
139
+
140
+ def dispose(self) -> None:
141
+ self.complete(None, None)
142
+
143
+ def close(self) -> None:
144
+ self.complete(None, None)
145
+
146
+ @staticmethod
147
+ def _resolve_operation(command: Command) -> Optional[str]:
148
+ if command.command_type in _SKIPPED_COMMANDS:
149
+ return None
150
+
151
+ operation = OperationName.resolve(command)
152
+ return None if operation.lower().endswith("telemetrysetup.initialize") else operation
153
+
154
+ @staticmethod
155
+ def _start(tracer: Tracer, name: str, headers: Mapping[Any, Any]) -> Span:
156
+ carrier = headers if headers is not None else {}
157
+
158
+ remote = CallSpan._PROPAGATOR.extract(carrier, context=Context(), getter=CallSpan._HEADER_GETTER)
159
+ if trace.get_current_span(remote).get_span_context().is_valid:
160
+ return tracer.start_span(name, context=remote, kind=SpanKind.INTERNAL)
161
+
162
+ trace_id = CallSpan._read_trace_id(_header(carrier, CallSpan.TRACE_ID_HEADER))
163
+ if trace_id is not None and trace_id != trace.get_current_span().get_span_context().trace_id:
164
+ parent = SpanContext(
165
+ trace_id=trace_id,
166
+ span_id=CallSpan._random_span_id(),
167
+ is_remote=True,
168
+ trace_flags=TraceFlags(TraceFlags.SAMPLED))
169
+ parent_context = trace.set_span_in_context(NonRecordingSpan(parent), Context())
170
+ return tracer.start_span(name, context=parent_context, kind=SpanKind.INTERNAL)
171
+
172
+ return tracer.start_span(name, kind=SpanKind.INTERNAL)
173
+
174
+ @staticmethod
175
+ def _read_trace_id(raw: Optional[str]) -> Optional[int]:
176
+ if raw is None:
177
+ return None
178
+
179
+ hex_value = str(raw).strip().replace("-", "").lower()
180
+ if len(hex_value) != 32 or not hex_value.replace("0", ""):
181
+ return None
182
+
183
+ if any(character not in "0123456789abcdef" for character in hex_value):
184
+ return None
185
+
186
+ return int(hex_value, 16)
187
+
188
+ @staticmethod
189
+ def _random_span_id() -> int:
190
+ span_id = 0
191
+ while span_id == 0:
192
+ span_id = random.getrandbits(64)
193
+ return span_id
194
+
195
+ def _inject_into(self, connection_data: IConnectionData) -> None:
196
+ local = connection_data.get_thread_local_headers()
197
+ span_context = self.span.get_span_context()
198
+ if (not span_context.is_valid
199
+ or (CallSpan.TRACE_PARENT_HEADER in connection_data.get_headers()
200
+ and CallSpan.TRACE_PARENT_HEADER not in local)):
201
+ return
202
+
203
+ headers = dict(local)
204
+ headers.pop(CallSpan.TRACE_STATE_HEADER, None)
205
+ CallSpan._PROPAGATOR.inject(headers, context=trace.set_span_in_context(self.span, Context()))
206
+ headers[CallSpan.TRACE_ID_HEADER] = trace.format_trace_id(span_context.trace_id)
207
+
208
+ connection_data.set_thread_local_headers(headers)
209
+ self._injected_into = connection_data
210
+ self._previous_headers = local
211
+
212
+ def _capture(self, command: Any, body_key: str, truncated_key: str) -> None:
213
+ if not self.span.is_recording() or not GraftcodeTelemetrySettings.is_body_capture_enabled():
214
+ return
215
+
216
+ result = BodyCapture.render(command, GraftcodeTelemetrySettings.get_body_max_characters())
217
+ if result is None or result.body is None:
218
+ return
219
+
220
+ self.span.set_attribute(body_key, result.body)
221
+ if result.truncated:
222
+ self.span.set_attribute(truncated_key, True)
223
+
224
+ @staticmethod
225
+ def _set_if_present(span: Span, key: str, value: Optional[str]) -> None:
226
+ if value is not None:
227
+ span.set_attribute(key, value)