graftcode-instrumentation 1.0.0__py3-none-any.whl

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.
@@ -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)
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import traceback
5
+ from types import TracebackType
6
+ from typing import Any, Dict, Mapping, Optional, Type
7
+
8
+ from opentelemetry import trace
9
+ from opentelemetry.sdk.trace import TracerProvider
10
+ from opentelemetry.trace import Tracer
11
+
12
+ from hypertube.utils.abstractions.HypertubeBroker import HypertubeBroker
13
+ from hypertube.utils.abstractions.HypertubeCallContext import HypertubeCallContext
14
+ from hypertube.utils.abstractions.IHypertubeHandling import IHypertubeHandling
15
+ from hypertube.utils.abstractions.IHypertubeInvocation import IHypertubeInvocation
16
+ from hypertube.utils.abstractions.IHypertubeLogging import IHypertubeLogging
17
+ from hypertube.utils.Command import Command
18
+ from hypertube.utils.connectionData.IConnectionData import IConnectionData
19
+ from hypertube.utils.logging.HypertubeLogger import HypertubeLogger
20
+ from hypertube.utils.logging.LogsCommandResolver import LogsCommandResolver
21
+ from hypertube.utils.logging.LogsLevel import LogsLevel
22
+ from hypertube.utils.logging.LogsRedactor import LogsRedactor
23
+
24
+ from graftcode_instrumentation.CallSpan import CallSpan
25
+ from graftcode_instrumentation.OperationName import OperationName
26
+
27
+ TRACE = 5
28
+
29
+ _LOG_LEVELS: Dict[LogsLevel, int] = {
30
+ LogsLevel.Trace: TRACE,
31
+ LogsLevel.Debug: logging.DEBUG,
32
+ LogsLevel.Info: logging.INFO,
33
+ LogsLevel.Warning: logging.WARNING,
34
+ LogsLevel.Error: logging.ERROR,
35
+ }
36
+
37
+ _ENABLED_PROBE_LEVELS = (logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG, TRACE)
38
+
39
+ _RESERVED_RECORD_ATTRIBUTES = frozenset(
40
+ logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()) | {"message", "asctime"}
41
+
42
+
43
+ class GraftcodeInstrumentation(IHypertubeInvocation, IHypertubeHandling, IHypertubeLogging):
44
+ INSTRUMENTATION_NAME = "Graftcode"
45
+ LOGGER_NAME = "Hypertube"
46
+
47
+ def __init__(
48
+ self,
49
+ tracer_provider: Optional[TracerProvider] = None,
50
+ logger: Optional[logging.Logger] = None,
51
+ ) -> None:
52
+ self._tracer: Tracer = trace.get_tracer(
53
+ GraftcodeInstrumentation.INSTRUMENTATION_NAME,
54
+ tracer_provider=tracer_provider,
55
+ )
56
+ self._logger = logger
57
+ HypertubeBroker.attach(self)
58
+ HypertubeLogger.attach(self)
59
+
60
+ @staticmethod
61
+ def attach(
62
+ tracer_provider: Optional[TracerProvider] = None,
63
+ logger: Optional[logging.Logger] = None,
64
+ ) -> GraftcodeInstrumentation:
65
+ return GraftcodeInstrumentation(tracer_provider, logger)
66
+
67
+ def on_execute(
68
+ self,
69
+ context: HypertubeCallContext,
70
+ command: Command,
71
+ connection_data: IConnectionData,
72
+ ) -> None:
73
+ reported = LogsCommandResolver.resolve_for_invoke(command)
74
+ span = CallSpan.start_invoke(self._tracer, reported, connection_data)
75
+ if span is not None:
76
+ context.items.append(span)
77
+
78
+ def on_response(self, context: HypertubeCallContext, response: Any) -> None:
79
+ GraftcodeInstrumentation._complete_spans(context, response, None)
80
+
81
+ def on_invocation_error(self, context: HypertubeCallContext, exception: BaseException) -> None:
82
+ GraftcodeInstrumentation._complete_spans(context, None, exception)
83
+
84
+ def on_process(self, context: HypertubeCallContext, command: Command) -> None:
85
+ headers: Dict[str, str] = {}
86
+ reported = LogsCommandResolver.resolve_for_handle(command, headers)
87
+ span = CallSpan.start_handle(self._tracer, reported, headers)
88
+ if span is not None:
89
+ context.items.append(span)
90
+
91
+ def on_processed(self, context: HypertubeCallContext, response: Any) -> None:
92
+ GraftcodeInstrumentation._complete_spans(context, response, None)
93
+
94
+ def on_handling_error(self, context: HypertubeCallContext, exception: BaseException) -> None:
95
+ GraftcodeInstrumentation._complete_spans(context, None, exception)
96
+
97
+ def is_requested(self) -> bool:
98
+ if self._logger is None:
99
+ return trace.get_current_span().is_recording()
100
+
101
+ return any(self._logger.isEnabledFor(level) for level in _ENABLED_PROBE_LEVELS)
102
+
103
+ def is_log_enabled(self, level: LogsLevel) -> bool:
104
+ if self._logger is None:
105
+ return trace.get_current_span().is_recording()
106
+
107
+ python_level = _LOG_LEVELS.get(level)
108
+ return python_level is not None and self._logger.isEnabledFor(python_level)
109
+
110
+ def on_log(
111
+ self,
112
+ level: LogsLevel,
113
+ message: str,
114
+ properties: Mapping[str, Any],
115
+ exception: Optional[BaseException],
116
+ ) -> None:
117
+ if self._logger is None:
118
+ GraftcodeInstrumentation._record_span_event(level, message, properties, exception)
119
+ return
120
+
121
+ if not self.is_log_enabled(level):
122
+ return
123
+
124
+ extra: Dict[str, Any] = {}
125
+ for key, value in properties.items():
126
+ extra[key + "_" if key in _RESERVED_RECORD_ATTRIBUTES else key] = value
127
+
128
+ exc_info = (type(exception), exception, exception.__traceback__) if exception is not None else None
129
+ self._logger.log(_LOG_LEVELS[level], message, exc_info=exc_info, extra=extra)
130
+
131
+ def close(self) -> None:
132
+ HypertubeBroker.detach(self)
133
+ HypertubeLogger.detach(self)
134
+
135
+ def dispose(self) -> None:
136
+ self.close()
137
+
138
+ def __enter__(self) -> GraftcodeInstrumentation:
139
+ return self
140
+
141
+ def __exit__(
142
+ self,
143
+ exc_type: Optional[Type[BaseException]],
144
+ exc: Optional[BaseException],
145
+ exc_traceback: Optional[TracebackType],
146
+ ) -> bool:
147
+ self.close()
148
+ return False
149
+
150
+ @staticmethod
151
+ def _complete_spans(context: HypertubeCallContext, response: Any, exception: Optional[BaseException]) -> None:
152
+ items = context.items
153
+ for i in range(len(items) - 1, -1, -1):
154
+ item = items[i]
155
+ if isinstance(item, CallSpan):
156
+ item.complete(response, exception)
157
+ del items[i]
158
+
159
+ @staticmethod
160
+ def _record_span_event(
161
+ level: LogsLevel,
162
+ message: str,
163
+ properties: Mapping[str, Any],
164
+ exception: Optional[BaseException],
165
+ ) -> None:
166
+ span = trace.get_current_span()
167
+ if not span.is_recording():
168
+ return
169
+
170
+ attributes: Dict[str, Any] = {"log.level": GraftcodeInstrumentation._to_level_name(level)}
171
+ attributes.update(properties)
172
+
173
+ if exception is not None:
174
+ attributes["log.message"] = message
175
+ attributes["exception.type"] = OperationName._full_name(type(exception))
176
+ GraftcodeInstrumentation._put_if_present(
177
+ attributes, "exception.message", LogsRedactor.redact_value(str(exception)))
178
+ GraftcodeInstrumentation._put_if_present(
179
+ attributes,
180
+ "exception.stacktrace",
181
+ LogsRedactor.redact_value(GraftcodeInstrumentation._stack_trace(exception)),
182
+ )
183
+
184
+ span.add_event("exception" if exception is not None else message, attributes)
185
+
186
+ @staticmethod
187
+ def _stack_trace(exception: BaseException) -> str:
188
+ return "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
189
+
190
+ @staticmethod
191
+ def _put_if_present(attributes: Dict[str, Any], key: str, value: Any) -> None:
192
+ if value:
193
+ attributes[key] = value
194
+
195
+ @staticmethod
196
+ def _to_level_name(level: LogsLevel) -> str:
197
+ return "Information" if level == LogsLevel.Info else level.name
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def _read_flag(name: str, default_value: bool) -> bool:
7
+ value = os.environ.get(name)
8
+ if value is None or not value.strip():
9
+ return default_value
10
+
11
+ return value.strip().lower() != "false" and value.strip() != "0"
12
+
13
+
14
+ class GraftcodeTelemetrySettings:
15
+ MINIMUM_BODY_MAX_CHARS = 256
16
+ DEFAULT_BODY_MAX_CHARS = 8192
17
+ MAXIMUM_BODY_MAX_CHARS = 32768
18
+
19
+ _auto_inject: bool = _read_flag("HYPERTUBE_TELEMETRY_AUTO_INJECT", True)
20
+ _capture_bodies: bool = _read_flag("HYPERTUBE_TELEMETRY_CAPTURE_BODIES", False)
21
+ _body_max_characters: int | None = None
22
+
23
+ @staticmethod
24
+ def is_auto_inject_enabled() -> bool:
25
+ return GraftcodeTelemetrySettings._auto_inject
26
+
27
+ @staticmethod
28
+ def set_auto_inject_enabled(value: bool) -> None:
29
+ GraftcodeTelemetrySettings._auto_inject = bool(value)
30
+
31
+ @staticmethod
32
+ def is_body_capture_enabled() -> bool:
33
+ return GraftcodeTelemetrySettings._capture_bodies
34
+
35
+ @staticmethod
36
+ def set_body_capture_enabled(value: bool) -> None:
37
+ GraftcodeTelemetrySettings._capture_bodies = bool(value)
38
+
39
+ @staticmethod
40
+ def get_body_max_characters() -> int | None:
41
+ return GraftcodeTelemetrySettings._body_max_characters
42
+
43
+ @staticmethod
44
+ def set_body_max_characters(value: int) -> None:
45
+ GraftcodeTelemetrySettings._body_max_characters = (
46
+ GraftcodeTelemetrySettings.clamp_body_max_characters(value))
47
+
48
+ @staticmethod
49
+ def clamp_body_max_characters(value: int) -> int:
50
+ if value < GraftcodeTelemetrySettings.MINIMUM_BODY_MAX_CHARS:
51
+ return GraftcodeTelemetrySettings.MINIMUM_BODY_MAX_CHARS
52
+
53
+ return min(value, GraftcodeTelemetrySettings.MAXIMUM_BODY_MAX_CHARS)
54
+
55
+ @staticmethod
56
+ def _read_body_max_chars() -> int:
57
+ raw = os.environ.get("HYPERTUBE_TELEMETRY_BODY_MAX_CHARS")
58
+ if raw is None or not raw.strip():
59
+ return GraftcodeTelemetrySettings.DEFAULT_BODY_MAX_CHARS
60
+
61
+ try:
62
+ return GraftcodeTelemetrySettings.clamp_body_max_characters(int(raw.strip()))
63
+ except ValueError:
64
+ return GraftcodeTelemetrySettings.DEFAULT_BODY_MAX_CHARS
65
+
66
+
67
+ GraftcodeTelemetrySettings._body_max_characters = GraftcodeTelemetrySettings._read_body_max_chars()
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ import zipfile
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from hypertube.utils.Command import Command
9
+ from hypertube.utils.CommandType import CommandType
10
+ from hypertube.utils.connectionData.IConnectionData import IConnectionData
11
+
12
+ from graftcode_instrumentation.OperationName import OperationName
13
+
14
+
15
+ class _Library:
16
+ __slots__ = ("name", "version")
17
+
18
+ def __init__(self, name: str, version: str) -> None:
19
+ self.name = name
20
+ self.version = version
21
+
22
+
23
+ class LibraryCatalog:
24
+ _libraries_by_connection: Dict[str, Dict[str, _Library]] = {}
25
+ _version_by_path: Dict[str, str] = {}
26
+ _lock = threading.Lock()
27
+
28
+ @staticmethod
29
+ def record(command: Command, connection_data: IConnectionData) -> None:
30
+ for load in LibraryCatalog._find_commands(command, CommandType.LoadLibrary):
31
+ LibraryCatalog._record_load(load, connection_data)
32
+
33
+ @staticmethod
34
+ def describe_target(command: Command, connection_data: IConnectionData) -> str:
35
+ channel = LibraryCatalog.channel(connection_data)
36
+ library = LibraryCatalog._resolve_library_name(command, connection_data)
37
+
38
+ if OperationName.is_blank(library):
39
+ return str(command.runtime_name) + " " + channel
40
+
41
+ version = LibraryCatalog._lookup_version(connection_data, library)
42
+ if OperationName.is_blank(version):
43
+ return library + " " + channel
44
+ return library + " " + version + " " + channel
45
+
46
+ @staticmethod
47
+ def channel(connection_data: IConnectionData) -> str:
48
+ return connection_data.connection_type.name.replace("_", "").lower()
49
+
50
+ @staticmethod
51
+ def _record_load(command: Command, connection_data: IConnectionData) -> None:
52
+ path = LibraryCatalog._resolve_library_path(command)
53
+ if OperationName.is_blank(path):
54
+ return
55
+
56
+ name = LibraryCatalog._file_name_without_extension(path)
57
+ if OperationName.is_blank(name):
58
+ return
59
+
60
+ with LibraryCatalog._lock:
61
+ version = LibraryCatalog._version_by_path.get(path)
62
+ if version is None:
63
+ version = LibraryCatalog._read_version(path, name)
64
+ LibraryCatalog._version_by_path[path] = version
65
+
66
+ libraries = LibraryCatalog._libraries_by_connection.setdefault(
67
+ LibraryCatalog._connection_key(connection_data), {})
68
+ libraries[name.lower()] = _Library(name, version)
69
+
70
+ @staticmethod
71
+ def _find_commands(command: Command, command_type: CommandType) -> List[Command]:
72
+ matches: List[Command] = []
73
+ LibraryCatalog._collect(command, command_type, matches)
74
+ return matches
75
+
76
+ @staticmethod
77
+ def _collect(command: Command, command_type: CommandType, matches: List[Command]) -> None:
78
+ if command.command_type == command_type:
79
+ matches.append(command)
80
+
81
+ for item in command.payload:
82
+ if isinstance(item, Command):
83
+ LibraryCatalog._collect(item, command_type, matches)
84
+
85
+ @staticmethod
86
+ def _resolve_library_name(command: Command, connection_data: IConnectionData) -> Optional[str]:
87
+ for load in LibraryCatalog._find_commands(command, CommandType.LoadLibrary):
88
+ path = LibraryCatalog._resolve_library_path(load)
89
+ name = None if OperationName.is_blank(path) else LibraryCatalog._file_name_without_extension(path)
90
+ if not OperationName.is_blank(name):
91
+ return name
92
+
93
+ type_name = OperationName.resolve_target_type_name(command)
94
+ if OperationName.is_blank(type_name):
95
+ return None
96
+
97
+ libraries = LibraryCatalog._libraries_by_connection.get(LibraryCatalog._connection_key(connection_data))
98
+ if not libraries:
99
+ return LibraryCatalog._root_namespace(type_name)
100
+
101
+ best_match: Optional[str] = None
102
+ for library in list(libraries.values()):
103
+ if not LibraryCatalog._is_type_of_library(type_name, library.name):
104
+ continue
105
+
106
+ if best_match is None or len(library.name) > len(best_match):
107
+ best_match = library.name
108
+
109
+ return best_match if best_match is not None else LibraryCatalog._root_namespace(type_name)
110
+
111
+ @staticmethod
112
+ def _root_namespace(type_name: str) -> str:
113
+ separator = type_name.find(".")
114
+ return type_name if separator < 0 else type_name[:separator]
115
+
116
+ @staticmethod
117
+ def _is_type_of_library(type_name: str, library_name: str) -> bool:
118
+ type_lower = type_name.lower()
119
+ library_lower = library_name.lower()
120
+ return type_lower == library_lower or type_lower.startswith(library_lower + ".")
121
+
122
+ @staticmethod
123
+ def _lookup_version(connection_data: IConnectionData, library: str) -> Optional[str]:
124
+ libraries = LibraryCatalog._libraries_by_connection.get(LibraryCatalog._connection_key(connection_data))
125
+ if not libraries:
126
+ return None
127
+
128
+ entry = libraries.get(library.lower())
129
+ return None if entry is None else entry.version
130
+
131
+ @staticmethod
132
+ def _connection_key(connection_data: IConnectionData) -> str:
133
+ return connection_data.connection_type.name + "|" + str(connection_data.hostname)
134
+
135
+ @staticmethod
136
+ def _resolve_library_path(command: Command) -> Optional[str]:
137
+ payload = command.payload
138
+ for i in range(len(payload) - 1, -1, -1):
139
+ path = LibraryCatalog._as_text(payload[i])
140
+ if not OperationName.is_blank(path):
141
+ return path
142
+
143
+ return None
144
+
145
+ @staticmethod
146
+ def _as_text(payload_item: Any) -> Optional[str]:
147
+ if isinstance(payload_item, Command):
148
+ if (payload_item.command_type == CommandType.Value
149
+ and payload_item.payload
150
+ and isinstance(payload_item.payload[0], str)):
151
+ return payload_item.payload[0]
152
+ return None
153
+
154
+ return payload_item if isinstance(payload_item, str) else None
155
+
156
+ @staticmethod
157
+ def _file_name_without_extension(path: str) -> str:
158
+ name = os.path.basename(path.rstrip("/\\"))
159
+ dot = name.rfind(".")
160
+ return name[:dot] if dot > 0 else name
161
+
162
+ @staticmethod
163
+ def _read_version(path: str, name: str) -> str:
164
+ if path.lower().endswith(".jar") and os.path.isfile(path):
165
+ return LibraryCatalog._read_jar_version(path)
166
+
167
+ if os.path.isdir(path) or path.lower().endswith(".py"):
168
+ return LibraryCatalog._read_distribution_version(name)
169
+
170
+ return ""
171
+
172
+ @staticmethod
173
+ def _read_jar_version(path: str) -> str:
174
+ try:
175
+ with zipfile.ZipFile(path) as jar:
176
+ manifest = jar.read("META-INF/MANIFEST.MF").decode("utf-8", errors="replace")
177
+ except (OSError, KeyError, zipfile.BadZipFile):
178
+ return ""
179
+
180
+ attributes: Dict[str, str] = {}
181
+ for line in manifest.splitlines():
182
+ key, separator, value = line.partition(":")
183
+ if separator:
184
+ attributes[key.strip()] = value.strip()
185
+
186
+ version = attributes.get("Implementation-Version") or attributes.get("Bundle-Version")
187
+ return "" if OperationName.is_blank(version) else version.strip()
188
+
189
+ @staticmethod
190
+ def _read_distribution_version(name: str) -> str:
191
+ try:
192
+ from importlib import metadata
193
+ return metadata.version(name).strip()
194
+ except Exception:
195
+ return ""
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Type
4
+
5
+ from hypertube.utils.Command import Command
6
+ from hypertube.utils.CommandType import CommandType
7
+
8
+ _MAX_DEPTH = 32
9
+
10
+ _MEMBER_COMMANDS = (
11
+ CommandType.InvokeStaticMethod,
12
+ CommandType.InvokeInstanceMethod,
13
+ CommandType.InvokeGenericStaticMethod,
14
+ CommandType.InvokeGenericMethod,
15
+ CommandType.GetStaticField,
16
+ CommandType.SetStaticField,
17
+ CommandType.GetInstanceField,
18
+ CommandType.SetInstanceField,
19
+ CommandType.GetStaticMethodAsDelegate,
20
+ CommandType.GetInstanceMethodAsDelegate,
21
+ )
22
+
23
+ _NAMED_BY_PAYLOAD = (
24
+ CommandType.GetType,
25
+ CommandType.Cast,
26
+ CommandType.CreateNull,
27
+ )
28
+
29
+ _TYPED_BY_FIRST_PAYLOAD = (
30
+ CommandType.CreateClassInstance,
31
+ CommandType.SetInstanceField,
32
+ CommandType.InvokeStaticMethod,
33
+ CommandType.InvokeInstanceMethod,
34
+ CommandType.GetStaticField,
35
+ CommandType.GetInstanceField,
36
+ )
37
+
38
+
39
+ class OperationName:
40
+
41
+ @staticmethod
42
+ def resolve(command: Command) -> str:
43
+ command_type = command.command_type
44
+
45
+ if command_type in _MEMBER_COMMANDS:
46
+ qualified = OperationName._qualify(
47
+ OperationName._resolve_type_name_core(OperationName._payload_at(command, 0), 0),
48
+ OperationName._resolve_text(OperationName._payload_at(command, 1), 0))
49
+ return qualified if qualified is not None else OperationName._fallback(command)
50
+
51
+ if command_type == CommandType.CreateClassInstance:
52
+ created_type = OperationName._resolve_type_name_core(OperationName._payload_at(command, 0), 0)
53
+ return OperationName._fallback(command) if created_type is None else created_type + ".ctor"
54
+
55
+ if command_type in (CommandType.InvokeGlobalFunction, CommandType.GetGlobalField):
56
+ name = OperationName._resolve_text(OperationName._payload_at(command, 0), 0)
57
+ return name if name is not None else OperationName._fallback(command)
58
+
59
+ return OperationName._fallback(command)
60
+
61
+ @staticmethod
62
+ def resolve_target_type_name(command: Command) -> Optional[str]:
63
+ command_type = command.command_type
64
+
65
+ if command_type in _MEMBER_COMMANDS or command_type == CommandType.CreateClassInstance:
66
+ return OperationName._resolve_type_name_core(OperationName._payload_at(command, 0), 0)
67
+
68
+ if command_type == CommandType.GetType:
69
+ return OperationName._resolve_type_name_core(command, 0)
70
+
71
+ return None
72
+
73
+ @staticmethod
74
+ def is_blank(value: Any) -> bool:
75
+ return value is None or not str(value).strip()
76
+
77
+ @staticmethod
78
+ def _fallback(command: Command) -> str:
79
+ return "hypertube." + command.command_type.name
80
+
81
+ @staticmethod
82
+ def _qualify(type_name: Optional[str], member_name: Optional[str]) -> Optional[str]:
83
+ if OperationName.is_blank(member_name):
84
+ return None
85
+
86
+ return member_name if OperationName.is_blank(type_name) else type_name + "." + member_name
87
+
88
+ @staticmethod
89
+ def _payload_at(command: Command, index: int) -> Any:
90
+ payload = command.payload
91
+ return payload[index] if len(payload) > index else None
92
+
93
+ @staticmethod
94
+ def _resolve_text(payload_item: Any, depth: int) -> Optional[str]:
95
+ if depth > _MAX_DEPTH:
96
+ return None
97
+
98
+ if isinstance(payload_item, str):
99
+ return payload_item
100
+
101
+ if isinstance(payload_item, Command) and payload_item.command_type == CommandType.Value:
102
+ return OperationName._resolve_text(OperationName._payload_at(payload_item, 0), depth + 1)
103
+
104
+ return None
105
+
106
+ @staticmethod
107
+ def _resolve_type_name_core(target: Any, depth: int) -> Optional[str]:
108
+ if depth > _MAX_DEPTH or target is None:
109
+ return None
110
+
111
+ if isinstance(target, str):
112
+ return target
113
+
114
+ if isinstance(target, type):
115
+ return OperationName._full_name(target)
116
+
117
+ if isinstance(target, Command):
118
+ return OperationName._resolve_type_name_from_command(target, depth)
119
+
120
+ return OperationName._full_name(type(target))
121
+
122
+ @staticmethod
123
+ def _resolve_type_name_from_command(command: Command, depth: int) -> Optional[str]:
124
+ command_type = command.command_type
125
+
126
+ if command_type == CommandType.Value:
127
+ return OperationName._resolve_type_name_core(OperationName._payload_at(command, 0), depth + 1)
128
+
129
+ if command_type in _NAMED_BY_PAYLOAD:
130
+ for item in command.payload:
131
+ name = OperationName._resolve_text(item, depth + 1)
132
+ if not OperationName.is_blank(name):
133
+ return name
134
+ return None
135
+
136
+ if command_type in _TYPED_BY_FIRST_PAYLOAD:
137
+ return OperationName._resolve_type_name_core(OperationName._payload_at(command, 0), depth + 1)
138
+
139
+ return None
140
+
141
+ @staticmethod
142
+ def _full_name(value_type: Type[Any]) -> str:
143
+ module = value_type.__module__
144
+ if module in (None, "builtins"):
145
+ return value_type.__qualname__
146
+ return module + "." + value_type.__qualname__
@@ -0,0 +1,12 @@
1
+ from graftcode_instrumentation.GraftcodeInstrumentation import GraftcodeInstrumentation
2
+ from graftcode_instrumentation.GraftcodeTelemetrySettings import GraftcodeTelemetrySettings
3
+
4
+ INSTRUMENTATION_NAME = GraftcodeInstrumentation.INSTRUMENTATION_NAME
5
+ LOGGER_NAME = GraftcodeInstrumentation.LOGGER_NAME
6
+
7
+ __all__ = [
8
+ "GraftcodeInstrumentation",
9
+ "GraftcodeTelemetrySettings",
10
+ "INSTRUMENTATION_NAME",
11
+ "LOGGER_NAME",
12
+ ]
@@ -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,11 @@
1
+ graftcode_instrumentation/BodyCapture.py,sha256=eaMyZK1qkPukOpVRRnLwk9zGEPUsd_8mBy5jOuswalE,5708
2
+ graftcode_instrumentation/CallSpan.py,sha256=ZAj0PmuB9VM1C9-TFWDrdoNvpX10dc1pc8Gvu3t8x7Y,9153
3
+ graftcode_instrumentation/GraftcodeInstrumentation.py,sha256=zz1r3_ha32tcNtY7aPpQu_ikURLzJ5v722Wa8ifc1Yc,7530
4
+ graftcode_instrumentation/GraftcodeTelemetrySettings.py,sha256=pXQdXNlUCNqki9SGydXwnERfX2diywE9Df8uCfQKG2g,2298
5
+ graftcode_instrumentation/LibraryCatalog.py,sha256=TDMLmn5-wUxI9BlhZYj9eUsWSQaaGd1KHIbVIzjo6Iw,7472
6
+ graftcode_instrumentation/OperationName.py,sha256=Qyo1QKqePP-WTdzJ7CIw7oM7-G2_zoHvTwBVR2zSCEw,5098
7
+ graftcode_instrumentation/__init__.py,sha256=uZiLKbyDhNP4jz0nBRt1U5N3oLt7rVI9zYRlpFukJSs,429
8
+ graftcode_instrumentation-1.0.0.dist-info/METADATA,sha256=CD1E0W_fGYr2XpRtNf5QAfKu3yv17Je9m_fBqbBeAto,6473
9
+ graftcode_instrumentation-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ graftcode_instrumentation-1.0.0.dist-info/top_level.txt,sha256=cmcsqjzhcRirpveVx7r4P7CK2x6bCXTks33DEoMq4_Q,26
11
+ graftcode_instrumentation-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ graftcode_instrumentation