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