graftcode-telemetry-appinsights 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_telemetry_appinsights-1.0.0/PKG-INFO +118 -0
- graftcode_telemetry_appinsights-1.0.0/README.md +100 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/GraftCodeAppInsightsConnector.py +29 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorOptions.py +46 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorPipeline.py +243 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/GraftCodeAppInsightsLogger.py +152 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/GraftCodeAppInsightsRequestTelemetryMiddleware.py +123 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/MessageTemplate.py +70 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/RateLimitedSampler.py +48 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights/__init__.py +15 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights.egg-info/PKG-INFO +118 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights.egg-info/SOURCES.txt +18 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights.egg-info/dependency_links.txt +1 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights.egg-info/requires.txt +10 -0
- graftcode_telemetry_appinsights-1.0.0/graftcode_telemetry_appinsights.egg-info/top_level.txt +1 -0
- graftcode_telemetry_appinsights-1.0.0/pyproject.toml +33 -0
- graftcode_telemetry_appinsights-1.0.0/setup.cfg +4 -0
- graftcode_telemetry_appinsights-1.0.0/tests/test_GraftCodeAppInsightsConnector.py +154 -0
- graftcode_telemetry_appinsights-1.0.0/tests/test_GraftCodeAppInsightsLogger.py +121 -0
- graftcode_telemetry_appinsights-1.0.0/tests/test_GraftCodeAppInsightsRequestTelemetryMiddleware.py +126 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graftcode-telemetry-appinsights
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Application Insights connector for Graftcode / Hypertube telemetry 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.39
|
|
10
|
+
Requires-Dist: opentelemetry-sdk>=1.39
|
|
11
|
+
Requires-Dist: azure-monitor-opentelemetry-exporter>=1.0.0b40
|
|
12
|
+
Requires-Dist: graftcode-instrumentation>=1.0.0
|
|
13
|
+
Requires-Dist: hypertube-python-sdk>=3.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest; extra == "dev"
|
|
16
|
+
Requires-Dist: starlette; extra == "dev"
|
|
17
|
+
Requires-Dist: httpx; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# GraftCode App Insights Connector (Python)
|
|
20
|
+
|
|
21
|
+
Configures Application Insights for an application, together with the Graft bridge spans, in one call. It is a port of the .NET (`GraftCode.Netcore.Telemetry.AppInsightsConnector`) and Java (`com.graftcode.telemetry.appinsights`) connectors.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from graftcode_telemetry_appinsights import GraftCodeAppInsightsConnector
|
|
25
|
+
|
|
26
|
+
with GraftCodeAppInsightsConnector.start("MyService") as telemetry:
|
|
27
|
+
span = telemetry.start_span("MyOperation")
|
|
28
|
+
telemetry.logger.info("My operation started {Step}", "one")
|
|
29
|
+
span.end()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The connector reads `APPLICATIONINSIGHTS_CONNECTION_STRING` and exports traces and logs with the Azure Monitor exporter (`azure-monitor-opentelemetry-exporter`). It also attaches `graftcode-instrumentation`, so Hypertube calls produce `Graftcode` spans and Hypertube log lines go to the same pipeline. You can configure an explicit connection string with a callback or an options object:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from graftcode_telemetry_appinsights import GraftCodeAppInsightsConnector, GraftCodeAppInsightsConnectorOptions
|
|
36
|
+
|
|
37
|
+
def configure(options):
|
|
38
|
+
options.service_name = "MyService"
|
|
39
|
+
options.connection_string = settings["ApplicationInsights:ConnectionString"]
|
|
40
|
+
|
|
41
|
+
telemetry = GraftCodeAppInsightsConnector.start(configure)
|
|
42
|
+
telemetry = GraftCodeAppInsightsConnector.start(GraftCodeAppInsightsConnectorOptions(service_name="MyService"))
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Option | Default |
|
|
46
|
+
|--------|---------|
|
|
47
|
+
| `service_name` | required; becomes `service.name` (the App Insights cloud role) and the tracer/logger name |
|
|
48
|
+
| `connection_string` | falls back to `APPLICATIONINSIGHTS_CONNECTION_STRING` |
|
|
49
|
+
| `minimum_log_level` | `LogsLevel.Info`; applies to the connector logger and to forwarded Hypertube lines |
|
|
50
|
+
| `traces_per_second` | `None` (every trace is kept) |
|
|
51
|
+
| `enable_console_logging` | `True` |
|
|
52
|
+
|
|
53
|
+
## Pipeline (`GraftCodeAppInsightsConnectorPipeline`)
|
|
54
|
+
|
|
55
|
+
| Member | Purpose |
|
|
56
|
+
|--------|---------|
|
|
57
|
+
| `tracer`, `start_span(name, kind=INTERNAL, context=None)` | spans for the application |
|
|
58
|
+
| `logger` | `GraftCodeAppInsightsLogger` (`trace`, `debug`, `info`, `warning`, `error`, `log`) |
|
|
59
|
+
| `instrumentation` | the attached `GraftcodeInstrumentation` |
|
|
60
|
+
| `force_flush(timeout_millis=5000)` | flushes traces and logs |
|
|
61
|
+
| `close()` / context manager | flushes, detaches the instrumentation, and shuts the providers down; calling it twice is safe |
|
|
62
|
+
| `is_exporting_to_app_insights()` | `False` when no connection string was resolved |
|
|
63
|
+
|
|
64
|
+
The OpenTelemetry providers are not registered as globals, which matches the Java connector. Use `pipeline.tracer` or `pipeline.start_span` for application spans.
|
|
65
|
+
|
|
66
|
+
## Logger
|
|
67
|
+
|
|
68
|
+
The logger uses the same message templates as the .NET and Java connectors:
|
|
69
|
+
|
|
70
|
+
- `{Name}` placeholders are filled positionally. `{{` and `}}` escape braces, and format specifiers (`{Count:D3}`) are ignored. `None` renders as `(null)`.
|
|
71
|
+
- Each placeholder becomes a log attribute. The raw template is stored as `{OriginalFormat}`.
|
|
72
|
+
- Records are correlated with the current span.
|
|
73
|
+
- Console output follows the .NET console layout:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
info: TestGgPythonWebApi[0]
|
|
77
|
+
Caller after Graft call Result=fetched
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To log an exception, pass it first: `logger.error(exception, "failed {Step}", "fetch")`. It is attached as `exception.type`, `exception.message` and `exception.stacktrace`.
|
|
81
|
+
|
|
82
|
+
Hypertube's own lines reach App Insights through the standard-library logger `Hypertube`. The pipeline sets that logger's level from `minimum_log_level` and adds a handler that emits through the OpenTelemetry logs API (instrumentation scope `Hypertube`).
|
|
83
|
+
|
|
84
|
+
## Request telemetry (ASGI)
|
|
85
|
+
|
|
86
|
+
Add the middleware to FastAPI or Starlette. It plays the role of `UseGraftCodeAppInsightsConnectorRequestTelemetry` in .NET and the servlet filter in Java:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
app.add_middleware(GraftCodeAppInsightsRequestTelemetryMiddleware, telemetry=telemetry)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Each HTTP request gets a `SERVER` span that is current while the endpoint runs:
|
|
93
|
+
|
|
94
|
+
- **Parent:** the incoming W3C `traceparent`, when present.
|
|
95
|
+
- **Attributes:** `http.method`, `http.target` and `http.status_code`. A status of 500 or higher marks the span as failed, and exceptions are recorded and re-raised. The path goes in `http.target` rather than `http.route` (as .NET and Java use) because the Python Azure Monitor exporter replaces the request name with `METHOD route` whenever `http.route` or a full URL is present; with `http.target` the request keeps the controller-based name, as in .NET and Java.
|
|
96
|
+
- **Name:** `{ServiceName}.{controller}` when the routed endpoint belongs to a class, for example a `FetchController.fetch` endpoint gives `TestGgPythonWebApi.Fetch`. An explicit name can be set in `scope["state"]["graft.controller"]`. Otherwise the name is `{ServiceName} {METHOD} {path}`.
|
|
97
|
+
|
|
98
|
+
Synchronous endpoints run in Starlette's thread pool with a copy of the context, so Graft calls made there share the request's trace.
|
|
99
|
+
|
|
100
|
+
## Sampling
|
|
101
|
+
|
|
102
|
+
Every trace is kept by default (`ALWAYS_ON`); the reason is the same as in .NET and Java. Set `traces_per_second` to opt into a token-bucket `RateLimitedSampler`.
|
|
103
|
+
|
|
104
|
+
## Flushing
|
|
105
|
+
|
|
106
|
+
The pipeline flushes on `close()`, at interpreter exit (`atexit`), and on unhandled exceptions; both `sys.excepthook` and `threading.excepthook` are chained. Short-lived processes should still call `force_flush()` before exiting.
|
|
107
|
+
|
|
108
|
+
## Diagnosing missing telemetry
|
|
109
|
+
|
|
110
|
+
When no connection string is resolved, `is_exporting_to_app_insights()` returns `False` and telemetry stays local. The connector then logs `Application Insights telemetry for {ServiceName} is disabled: no connection string was configured. ...`.
|
|
111
|
+
|
|
112
|
+
## Tests
|
|
113
|
+
|
|
114
|
+
```powershell
|
|
115
|
+
$env:PYTHONPATH = "<HYPERTUBE>\src\python;<graftcode-extensions>\observability\Instrumentation\python"
|
|
116
|
+
python -m pip install -e .[dev]
|
|
117
|
+
python -m pytest
|
|
118
|
+
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# GraftCode App Insights Connector (Python)
|
|
2
|
+
|
|
3
|
+
Configures Application Insights for an application, together with the Graft bridge spans, in one call. It is a port of the .NET (`GraftCode.Netcore.Telemetry.AppInsightsConnector`) and Java (`com.graftcode.telemetry.appinsights`) connectors.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from graftcode_telemetry_appinsights import GraftCodeAppInsightsConnector
|
|
7
|
+
|
|
8
|
+
with GraftCodeAppInsightsConnector.start("MyService") as telemetry:
|
|
9
|
+
span = telemetry.start_span("MyOperation")
|
|
10
|
+
telemetry.logger.info("My operation started {Step}", "one")
|
|
11
|
+
span.end()
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The connector reads `APPLICATIONINSIGHTS_CONNECTION_STRING` and exports traces and logs with the Azure Monitor exporter (`azure-monitor-opentelemetry-exporter`). It also attaches `graftcode-instrumentation`, so Hypertube calls produce `Graftcode` spans and Hypertube log lines go to the same pipeline. You can configure an explicit connection string with a callback or an options object:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from graftcode_telemetry_appinsights import GraftCodeAppInsightsConnector, GraftCodeAppInsightsConnectorOptions
|
|
18
|
+
|
|
19
|
+
def configure(options):
|
|
20
|
+
options.service_name = "MyService"
|
|
21
|
+
options.connection_string = settings["ApplicationInsights:ConnectionString"]
|
|
22
|
+
|
|
23
|
+
telemetry = GraftCodeAppInsightsConnector.start(configure)
|
|
24
|
+
telemetry = GraftCodeAppInsightsConnector.start(GraftCodeAppInsightsConnectorOptions(service_name="MyService"))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Option | Default |
|
|
28
|
+
|--------|---------|
|
|
29
|
+
| `service_name` | required; becomes `service.name` (the App Insights cloud role) and the tracer/logger name |
|
|
30
|
+
| `connection_string` | falls back to `APPLICATIONINSIGHTS_CONNECTION_STRING` |
|
|
31
|
+
| `minimum_log_level` | `LogsLevel.Info`; applies to the connector logger and to forwarded Hypertube lines |
|
|
32
|
+
| `traces_per_second` | `None` (every trace is kept) |
|
|
33
|
+
| `enable_console_logging` | `True` |
|
|
34
|
+
|
|
35
|
+
## Pipeline (`GraftCodeAppInsightsConnectorPipeline`)
|
|
36
|
+
|
|
37
|
+
| Member | Purpose |
|
|
38
|
+
|--------|---------|
|
|
39
|
+
| `tracer`, `start_span(name, kind=INTERNAL, context=None)` | spans for the application |
|
|
40
|
+
| `logger` | `GraftCodeAppInsightsLogger` (`trace`, `debug`, `info`, `warning`, `error`, `log`) |
|
|
41
|
+
| `instrumentation` | the attached `GraftcodeInstrumentation` |
|
|
42
|
+
| `force_flush(timeout_millis=5000)` | flushes traces and logs |
|
|
43
|
+
| `close()` / context manager | flushes, detaches the instrumentation, and shuts the providers down; calling it twice is safe |
|
|
44
|
+
| `is_exporting_to_app_insights()` | `False` when no connection string was resolved |
|
|
45
|
+
|
|
46
|
+
The OpenTelemetry providers are not registered as globals, which matches the Java connector. Use `pipeline.tracer` or `pipeline.start_span` for application spans.
|
|
47
|
+
|
|
48
|
+
## Logger
|
|
49
|
+
|
|
50
|
+
The logger uses the same message templates as the .NET and Java connectors:
|
|
51
|
+
|
|
52
|
+
- `{Name}` placeholders are filled positionally. `{{` and `}}` escape braces, and format specifiers (`{Count:D3}`) are ignored. `None` renders as `(null)`.
|
|
53
|
+
- Each placeholder becomes a log attribute. The raw template is stored as `{OriginalFormat}`.
|
|
54
|
+
- Records are correlated with the current span.
|
|
55
|
+
- Console output follows the .NET console layout:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
info: TestGgPythonWebApi[0]
|
|
59
|
+
Caller after Graft call Result=fetched
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To log an exception, pass it first: `logger.error(exception, "failed {Step}", "fetch")`. It is attached as `exception.type`, `exception.message` and `exception.stacktrace`.
|
|
63
|
+
|
|
64
|
+
Hypertube's own lines reach App Insights through the standard-library logger `Hypertube`. The pipeline sets that logger's level from `minimum_log_level` and adds a handler that emits through the OpenTelemetry logs API (instrumentation scope `Hypertube`).
|
|
65
|
+
|
|
66
|
+
## Request telemetry (ASGI)
|
|
67
|
+
|
|
68
|
+
Add the middleware to FastAPI or Starlette. It plays the role of `UseGraftCodeAppInsightsConnectorRequestTelemetry` in .NET and the servlet filter in Java:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
app.add_middleware(GraftCodeAppInsightsRequestTelemetryMiddleware, telemetry=telemetry)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each HTTP request gets a `SERVER` span that is current while the endpoint runs:
|
|
75
|
+
|
|
76
|
+
- **Parent:** the incoming W3C `traceparent`, when present.
|
|
77
|
+
- **Attributes:** `http.method`, `http.target` and `http.status_code`. A status of 500 or higher marks the span as failed, and exceptions are recorded and re-raised. The path goes in `http.target` rather than `http.route` (as .NET and Java use) because the Python Azure Monitor exporter replaces the request name with `METHOD route` whenever `http.route` or a full URL is present; with `http.target` the request keeps the controller-based name, as in .NET and Java.
|
|
78
|
+
- **Name:** `{ServiceName}.{controller}` when the routed endpoint belongs to a class, for example a `FetchController.fetch` endpoint gives `TestGgPythonWebApi.Fetch`. An explicit name can be set in `scope["state"]["graft.controller"]`. Otherwise the name is `{ServiceName} {METHOD} {path}`.
|
|
79
|
+
|
|
80
|
+
Synchronous endpoints run in Starlette's thread pool with a copy of the context, so Graft calls made there share the request's trace.
|
|
81
|
+
|
|
82
|
+
## Sampling
|
|
83
|
+
|
|
84
|
+
Every trace is kept by default (`ALWAYS_ON`); the reason is the same as in .NET and Java. Set `traces_per_second` to opt into a token-bucket `RateLimitedSampler`.
|
|
85
|
+
|
|
86
|
+
## Flushing
|
|
87
|
+
|
|
88
|
+
The pipeline flushes on `close()`, at interpreter exit (`atexit`), and on unhandled exceptions; both `sys.excepthook` and `threading.excepthook` are chained. Short-lived processes should still call `force_flush()` before exiting.
|
|
89
|
+
|
|
90
|
+
## Diagnosing missing telemetry
|
|
91
|
+
|
|
92
|
+
When no connection string is resolved, `is_exporting_to_app_insights()` returns `False` and telemetry stays local. The connector then logs `Application Insights telemetry for {ServiceName} is disabled: no connection string was configured. ...`.
|
|
93
|
+
|
|
94
|
+
## Tests
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
$env:PYTHONPATH = "<HYPERTUBE>\src\python;<graftcode-extensions>\observability\Instrumentation\python"
|
|
98
|
+
python -m pip install -e .[dev]
|
|
99
|
+
python -m pytest
|
|
100
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Union
|
|
4
|
+
|
|
5
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnectorOptions import GraftCodeAppInsightsConnectorOptions
|
|
6
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnectorPipeline import GraftCodeAppInsightsConnectorPipeline
|
|
7
|
+
|
|
8
|
+
ConfigureCallback = Callable[[GraftCodeAppInsightsConnectorOptions], None]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraftCodeAppInsightsConnector:
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def start(
|
|
15
|
+
service_name_or_configure: Union[str, ConfigureCallback, GraftCodeAppInsightsConnectorOptions, None],
|
|
16
|
+
) -> GraftCodeAppInsightsConnectorPipeline:
|
|
17
|
+
if service_name_or_configure is None:
|
|
18
|
+
raise ValueError("start requires a service name, a configure callback or options")
|
|
19
|
+
|
|
20
|
+
if isinstance(service_name_or_configure, GraftCodeAppInsightsConnectorOptions):
|
|
21
|
+
return GraftCodeAppInsightsConnectorPipeline(service_name_or_configure)
|
|
22
|
+
|
|
23
|
+
options = GraftCodeAppInsightsConnectorOptions()
|
|
24
|
+
if callable(service_name_or_configure):
|
|
25
|
+
service_name_or_configure(options)
|
|
26
|
+
else:
|
|
27
|
+
options.service_name = service_name_or_configure
|
|
28
|
+
|
|
29
|
+
return GraftCodeAppInsightsConnectorPipeline(options)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable, Optional, Union
|
|
5
|
+
|
|
6
|
+
from hypertube.utils.logging.LogsLevel import LogsLevel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GraftCodeAppInsightsConnectorOptions:
|
|
10
|
+
CONNECTION_STRING_VARIABLE = "APPLICATIONINSIGHTS_CONNECTION_STRING"
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
service_name: str = "",
|
|
15
|
+
connection_string: Optional[str] = None,
|
|
16
|
+
minimum_log_level: LogsLevel = LogsLevel.Info,
|
|
17
|
+
traces_per_second: Optional[float] = None,
|
|
18
|
+
enable_console_logging: bool = True,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.service_name = service_name
|
|
21
|
+
self.connection_string = connection_string
|
|
22
|
+
self.minimum_log_level = minimum_log_level
|
|
23
|
+
self.traces_per_second = traces_per_second
|
|
24
|
+
self.enable_console_logging = enable_console_logging
|
|
25
|
+
self._environment: Callable[[str], Optional[str]] = os.environ.get
|
|
26
|
+
|
|
27
|
+
def resolve_connection_string(self) -> Optional[str]:
|
|
28
|
+
if not GraftCodeAppInsightsConnectorOptions._is_blank(self.connection_string):
|
|
29
|
+
return self.connection_string
|
|
30
|
+
|
|
31
|
+
environment_value = self._environment(GraftCodeAppInsightsConnectorOptions.CONNECTION_STRING_VARIABLE)
|
|
32
|
+
return None if GraftCodeAppInsightsConnectorOptions._is_blank(environment_value) else environment_value
|
|
33
|
+
|
|
34
|
+
def validate(self) -> None:
|
|
35
|
+
if GraftCodeAppInsightsConnectorOptions._is_blank(self.service_name):
|
|
36
|
+
raise ValueError("service_name must be configured for Azure Monitor telemetry.")
|
|
37
|
+
|
|
38
|
+
if self.minimum_log_level is None:
|
|
39
|
+
raise ValueError("minimum_log_level must not be None.")
|
|
40
|
+
|
|
41
|
+
if self.traces_per_second is not None and not self.traces_per_second > 0:
|
|
42
|
+
raise ValueError("traces_per_second must be greater than zero.")
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _is_blank(value: Union[str, None]) -> bool:
|
|
46
|
+
return value is None or not str(value).strip()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import traceback
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Optional, Tuple, Type
|
|
10
|
+
|
|
11
|
+
from opentelemetry import context as otel_context
|
|
12
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
13
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
14
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
15
|
+
from opentelemetry.sdk.resources import Resource
|
|
16
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
17
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
18
|
+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
|
19
|
+
from opentelemetry.trace import Context, SpanKind
|
|
20
|
+
|
|
21
|
+
from hypertube.utils.logging.LogsLevel import LogsLevel
|
|
22
|
+
|
|
23
|
+
from graftcode_instrumentation import GraftcodeInstrumentation
|
|
24
|
+
|
|
25
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnectorOptions import GraftCodeAppInsightsConnectorOptions
|
|
26
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsLogger import GraftCodeAppInsightsLogger
|
|
27
|
+
from graftcode_telemetry_appinsights.RateLimitedSampler import RateLimitedSampler
|
|
28
|
+
|
|
29
|
+
_DEFAULT_FLUSH_MILLISECONDS = 5000
|
|
30
|
+
|
|
31
|
+
_MISSING_CONNECTION_STRING = (
|
|
32
|
+
"Application Insights telemetry for {ServiceName} is disabled: no connection string was configured. "
|
|
33
|
+
"Set " + GraftCodeAppInsightsConnectorOptions.CONNECTION_STRING_VARIABLE + " in this process or set "
|
|
34
|
+
"GraftCodeAppInsightsConnectorOptions.connection_string.")
|
|
35
|
+
|
|
36
|
+
_PYTHON_LEVELS = {
|
|
37
|
+
LogsLevel.Trace: 5,
|
|
38
|
+
LogsLevel.Debug: logging.DEBUG,
|
|
39
|
+
LogsLevel.Info: logging.INFO,
|
|
40
|
+
LogsLevel.Warning: logging.WARNING,
|
|
41
|
+
LogsLevel.Error: logging.ERROR,
|
|
42
|
+
LogsLevel.Off: logging.CRITICAL + 1,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_STANDARD_RECORD_ATTRIBUTES = frozenset(
|
|
46
|
+
logging.LogRecord("", 0, "", 0, "", None, None).__dict__.keys()) | {"message", "asctime", "taskName"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _HypertubeLogHandler(logging.Handler):
|
|
50
|
+
|
|
51
|
+
def __init__(self, logger_provider: LoggerProvider) -> None:
|
|
52
|
+
super().__init__(level=0)
|
|
53
|
+
self._logger = logger_provider.get_logger(GraftcodeInstrumentation.LOGGER_NAME)
|
|
54
|
+
|
|
55
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
56
|
+
try:
|
|
57
|
+
attributes = {
|
|
58
|
+
key: value for key, value in record.__dict__.items()
|
|
59
|
+
if key not in _STANDARD_RECORD_ATTRIBUTES and isinstance(value, (str, bool, int, float))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
63
|
+
exception = record.exc_info[1]
|
|
64
|
+
attributes["exception.type"] = type(exception).__qualname__
|
|
65
|
+
if str(exception):
|
|
66
|
+
attributes["exception.message"] = str(exception)
|
|
67
|
+
attributes["exception.stacktrace"] = "".join(traceback.format_exception(*record.exc_info))
|
|
68
|
+
|
|
69
|
+
self._logger.emit(LogRecord(
|
|
70
|
+
timestamp=int(record.created * 1e9),
|
|
71
|
+
context=otel_context.get_current(),
|
|
72
|
+
severity_number=_HypertubeLogHandler._severity(record.levelno),
|
|
73
|
+
severity_text="Information" if record.levelno == logging.INFO else record.levelname.capitalize(),
|
|
74
|
+
body=record.getMessage(),
|
|
75
|
+
attributes=attributes))
|
|
76
|
+
except Exception:
|
|
77
|
+
self.handleError(record)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _severity(level: int) -> SeverityNumber:
|
|
81
|
+
if level >= logging.ERROR:
|
|
82
|
+
return SeverityNumber.ERROR
|
|
83
|
+
if level >= logging.WARNING:
|
|
84
|
+
return SeverityNumber.WARN
|
|
85
|
+
if level >= logging.INFO:
|
|
86
|
+
return SeverityNumber.INFO
|
|
87
|
+
if level >= logging.DEBUG:
|
|
88
|
+
return SeverityNumber.DEBUG
|
|
89
|
+
return SeverityNumber.TRACE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GraftCodeAppInsightsConnectorPipeline:
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
options: GraftCodeAppInsightsConnectorOptions,
|
|
97
|
+
providers: Optional[Tuple[TracerProvider, LoggerProvider]] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
options.validate()
|
|
100
|
+
|
|
101
|
+
self._flush_gate = threading.Lock()
|
|
102
|
+
self._closed = False
|
|
103
|
+
self.service_name = options.service_name
|
|
104
|
+
connection_string = options.resolve_connection_string() if providers is None else None
|
|
105
|
+
self._exporting_to_app_insights = connection_string is not None
|
|
106
|
+
|
|
107
|
+
if providers is not None:
|
|
108
|
+
self.tracer_provider, self.logger_provider = providers
|
|
109
|
+
elif connection_string is not None:
|
|
110
|
+
self.tracer_provider, self.logger_provider = GraftCodeAppInsightsConnectorPipeline._build_exporting_sdk(
|
|
111
|
+
self.service_name, connection_string, options.traces_per_second)
|
|
112
|
+
else:
|
|
113
|
+
self.tracer_provider, self.logger_provider = GraftCodeAppInsightsConnectorPipeline._build_local_sdk(
|
|
114
|
+
self.service_name)
|
|
115
|
+
|
|
116
|
+
self.tracer = self.tracer_provider.get_tracer(self.service_name)
|
|
117
|
+
self.logger = GraftCodeAppInsightsLogger(
|
|
118
|
+
self.service_name,
|
|
119
|
+
self.logger_provider.get_logger(self.service_name),
|
|
120
|
+
options.minimum_log_level,
|
|
121
|
+
True if options.enable_console_logging else None)
|
|
122
|
+
|
|
123
|
+
self._hypertube_logger = logging.getLogger(GraftcodeInstrumentation.LOGGER_NAME)
|
|
124
|
+
self._hypertube_logger.setLevel(_PYTHON_LEVELS[options.minimum_log_level])
|
|
125
|
+
self._hypertube_logger.propagate = False
|
|
126
|
+
self._log_handler = _HypertubeLogHandler(self.logger_provider)
|
|
127
|
+
self._hypertube_logger.addHandler(self._log_handler)
|
|
128
|
+
self.instrumentation = GraftcodeInstrumentation.attach(self.tracer_provider, self._hypertube_logger)
|
|
129
|
+
|
|
130
|
+
if providers is None and connection_string is None:
|
|
131
|
+
self._warn_about_missing_connection_string(options)
|
|
132
|
+
|
|
133
|
+
atexit.register(self.close)
|
|
134
|
+
|
|
135
|
+
self._previous_excepthook = sys.excepthook
|
|
136
|
+
self._previous_thread_excepthook = threading.excepthook
|
|
137
|
+
sys.excepthook = self._on_uncaught_exception
|
|
138
|
+
threading.excepthook = self._on_uncaught_thread_exception
|
|
139
|
+
|
|
140
|
+
def is_exporting_to_app_insights(self) -> bool:
|
|
141
|
+
return self._exporting_to_app_insights
|
|
142
|
+
|
|
143
|
+
def start_span(
|
|
144
|
+
self,
|
|
145
|
+
name: str,
|
|
146
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
147
|
+
context: Optional[Context] = None,
|
|
148
|
+
):
|
|
149
|
+
return self.tracer.start_span(name, context=context, kind=kind)
|
|
150
|
+
|
|
151
|
+
def force_flush(self, timeout_millis: int = _DEFAULT_FLUSH_MILLISECONDS) -> bool:
|
|
152
|
+
with self._flush_gate:
|
|
153
|
+
if self._closed:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
traces = self.tracer_provider.force_flush(timeout_millis)
|
|
157
|
+
logs = self.logger_provider.force_flush(timeout_millis)
|
|
158
|
+
return bool(traces) and bool(logs)
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
with self._flush_gate:
|
|
162
|
+
if self._closed:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
self.tracer_provider.force_flush(_DEFAULT_FLUSH_MILLISECONDS)
|
|
166
|
+
self.logger_provider.force_flush(_DEFAULT_FLUSH_MILLISECONDS)
|
|
167
|
+
self._closed = True
|
|
168
|
+
|
|
169
|
+
self.instrumentation.close()
|
|
170
|
+
self._hypertube_logger.removeHandler(self._log_handler)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
atexit.unregister(self.close)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
if sys.excepthook == self._on_uncaught_exception:
|
|
178
|
+
sys.excepthook = self._previous_excepthook
|
|
179
|
+
if threading.excepthook == self._on_uncaught_thread_exception:
|
|
180
|
+
threading.excepthook = self._previous_thread_excepthook
|
|
181
|
+
|
|
182
|
+
self.tracer_provider.shutdown()
|
|
183
|
+
self.logger_provider.shutdown()
|
|
184
|
+
|
|
185
|
+
def dispose(self) -> None:
|
|
186
|
+
self.close()
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> GraftCodeAppInsightsConnectorPipeline:
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def __exit__(
|
|
192
|
+
self,
|
|
193
|
+
exc_type: Optional[Type[BaseException]],
|
|
194
|
+
exc: Optional[BaseException],
|
|
195
|
+
traceback_obj: Optional[TracebackType],
|
|
196
|
+
) -> bool:
|
|
197
|
+
self.close()
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _build_exporting_sdk(
|
|
202
|
+
service_name: str,
|
|
203
|
+
connection_string: str,
|
|
204
|
+
traces_per_second: Optional[float],
|
|
205
|
+
) -> Tuple[TracerProvider, LoggerProvider]:
|
|
206
|
+
from azure.monitor.opentelemetry.exporter import AzureMonitorLogExporter, AzureMonitorTraceExporter
|
|
207
|
+
|
|
208
|
+
sampler = ALWAYS_ON if traces_per_second is None else RateLimitedSampler(traces_per_second)
|
|
209
|
+
resource = Resource.create({"service.name": service_name})
|
|
210
|
+
|
|
211
|
+
tracer_provider = TracerProvider(resource=resource, sampler=sampler)
|
|
212
|
+
tracer_provider.add_span_processor(BatchSpanProcessor(AzureMonitorTraceExporter(
|
|
213
|
+
connection_string=connection_string, tracer_provider=tracer_provider)))
|
|
214
|
+
|
|
215
|
+
logger_provider = LoggerProvider(resource=resource)
|
|
216
|
+
logger_provider.add_log_record_processor(
|
|
217
|
+
BatchLogRecordProcessor(AzureMonitorLogExporter(connection_string=connection_string)))
|
|
218
|
+
|
|
219
|
+
return tracer_provider, logger_provider
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _build_local_sdk(service_name: str) -> Tuple[TracerProvider, LoggerProvider]:
|
|
223
|
+
resource = Resource.create({"service.name": service_name})
|
|
224
|
+
return TracerProvider(resource=resource), LoggerProvider(resource=resource)
|
|
225
|
+
|
|
226
|
+
def _warn_about_missing_connection_string(self, options: GraftCodeAppInsightsConnectorOptions) -> None:
|
|
227
|
+
self.logger.log(LogsLevel.Warning, _MISSING_CONNECTION_STRING, self.service_name)
|
|
228
|
+
|
|
229
|
+
if not options.enable_console_logging:
|
|
230
|
+
sys.stderr.write(_MISSING_CONNECTION_STRING.replace("{ServiceName}", self.service_name) + "\n")
|
|
231
|
+
|
|
232
|
+
def _on_uncaught_exception(
|
|
233
|
+
self,
|
|
234
|
+
exc_type: Optional[Type[BaseException]],
|
|
235
|
+
exc_value: Optional[BaseException],
|
|
236
|
+
exc_traceback: Optional[TracebackType],
|
|
237
|
+
) -> None:
|
|
238
|
+
self.force_flush()
|
|
239
|
+
self._previous_excepthook(exc_type, exc_value, exc_traceback)
|
|
240
|
+
|
|
241
|
+
def _on_uncaught_thread_exception(self, args: threading.ExceptHookArgs) -> None:
|
|
242
|
+
self.force_flush()
|
|
243
|
+
self._previous_thread_excepthook(args)
|