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.
- graftcode_instrumentation/BodyCapture.py +192 -0
- graftcode_instrumentation/CallSpan.py +227 -0
- graftcode_instrumentation/GraftcodeInstrumentation.py +197 -0
- graftcode_instrumentation/GraftcodeTelemetrySettings.py +67 -0
- graftcode_instrumentation/LibraryCatalog.py +195 -0
- graftcode_instrumentation/OperationName.py +146 -0
- graftcode_instrumentation/__init__.py +12 -0
- graftcode_instrumentation-1.0.0.dist-info/METADATA +105 -0
- graftcode_instrumentation-1.0.0.dist-info/RECORD +11 -0
- graftcode_instrumentation-1.0.0.dist-info/WHEEL +5 -0
- graftcode_instrumentation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
graftcode_instrumentation
|