graftcode-telemetry-appinsights 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_telemetry_appinsights/GraftCodeAppInsightsConnector.py +29 -0
- graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorOptions.py +46 -0
- graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorPipeline.py +243 -0
- graftcode_telemetry_appinsights/GraftCodeAppInsightsLogger.py +152 -0
- graftcode_telemetry_appinsights/GraftCodeAppInsightsRequestTelemetryMiddleware.py +123 -0
- graftcode_telemetry_appinsights/MessageTemplate.py +70 -0
- graftcode_telemetry_appinsights/RateLimitedSampler.py +48 -0
- graftcode_telemetry_appinsights/__init__.py +15 -0
- graftcode_telemetry_appinsights-1.0.0.dist-info/METADATA +118 -0
- graftcode_telemetry_appinsights-1.0.0.dist-info/RECORD +12 -0
- graftcode_telemetry_appinsights-1.0.0.dist-info/WHEEL +5 -0
- graftcode_telemetry_appinsights-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import IO, Any, Optional, TextIO, Union
|
|
9
|
+
|
|
10
|
+
from opentelemetry import context as otel_context
|
|
11
|
+
from opentelemetry._logs import Logger as OTelLogger, LogRecord
|
|
12
|
+
from opentelemetry._logs import SeverityNumber
|
|
13
|
+
|
|
14
|
+
from hypertube.utils.logging.LogsLevel import LogsLevel
|
|
15
|
+
|
|
16
|
+
from graftcode_telemetry_appinsights.MessageTemplate import MessageTemplate
|
|
17
|
+
|
|
18
|
+
_CONSOLE_INDENT = " "
|
|
19
|
+
|
|
20
|
+
_SEVERITIES = {
|
|
21
|
+
LogsLevel.Trace: SeverityNumber.TRACE,
|
|
22
|
+
LogsLevel.Debug: SeverityNumber.DEBUG,
|
|
23
|
+
LogsLevel.Info: SeverityNumber.INFO,
|
|
24
|
+
LogsLevel.Warning: SeverityNumber.WARN,
|
|
25
|
+
LogsLevel.Error: SeverityNumber.ERROR,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_CONSOLE_LEVELS = {
|
|
29
|
+
LogsLevel.Trace: "trce",
|
|
30
|
+
LogsLevel.Debug: "dbug",
|
|
31
|
+
LogsLevel.Info: "info",
|
|
32
|
+
LogsLevel.Warning: "warn",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraftCodeAppInsightsLogger:
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
category: str,
|
|
41
|
+
logger: OTelLogger,
|
|
42
|
+
minimum_level: LogsLevel,
|
|
43
|
+
console: Union[bool, TextIO, None],
|
|
44
|
+
) -> None:
|
|
45
|
+
self._category = category
|
|
46
|
+
self._logger = logger
|
|
47
|
+
self._minimum_level = minimum_level
|
|
48
|
+
self._console = console
|
|
49
|
+
self._console_lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def is_enabled(self, level: LogsLevel) -> bool:
|
|
52
|
+
return (level != LogsLevel.Off
|
|
53
|
+
and self._minimum_level != LogsLevel.Off
|
|
54
|
+
and level.is_at_least(self._minimum_level))
|
|
55
|
+
|
|
56
|
+
def trace(self, template: str, *args: Any) -> None:
|
|
57
|
+
self.log(LogsLevel.Trace, template, *args)
|
|
58
|
+
|
|
59
|
+
def debug(self, template: str, *args: Any) -> None:
|
|
60
|
+
self.log(LogsLevel.Debug, template, *args)
|
|
61
|
+
|
|
62
|
+
def info(self, template: str, *args: Any) -> None:
|
|
63
|
+
self.log(LogsLevel.Info, template, *args)
|
|
64
|
+
|
|
65
|
+
def warning(self, template: str, *args: Any) -> None:
|
|
66
|
+
self.log(LogsLevel.Warning, template, *args)
|
|
67
|
+
|
|
68
|
+
def error(self, exception_or_template: Union[BaseException, str], *args: Any) -> None:
|
|
69
|
+
if isinstance(exception_or_template, BaseException):
|
|
70
|
+
self.log_exception(LogsLevel.Error, exception_or_template, args[0] if args else None, *args[1:])
|
|
71
|
+
else:
|
|
72
|
+
self.log(LogsLevel.Error, exception_or_template, *args)
|
|
73
|
+
|
|
74
|
+
def log(self, level: LogsLevel, template: Optional[str], *args: Any) -> None:
|
|
75
|
+
self.log_exception(level, None, template, *args)
|
|
76
|
+
|
|
77
|
+
def log_exception(
|
|
78
|
+
self,
|
|
79
|
+
level: LogsLevel,
|
|
80
|
+
exception: Optional[BaseException],
|
|
81
|
+
template: Optional[str],
|
|
82
|
+
*args: Any,
|
|
83
|
+
) -> None:
|
|
84
|
+
if not self.is_enabled(level):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
properties: dict[str, str] = {}
|
|
88
|
+
message = MessageTemplate.render(template, args, properties)
|
|
89
|
+
|
|
90
|
+
self._emit(level, exception, template, message, properties)
|
|
91
|
+
self._write_to_console(level, exception, message)
|
|
92
|
+
|
|
93
|
+
def _emit(
|
|
94
|
+
self,
|
|
95
|
+
level: LogsLevel,
|
|
96
|
+
exception: Optional[BaseException],
|
|
97
|
+
template: Optional[str],
|
|
98
|
+
message: str,
|
|
99
|
+
properties: dict[str, str],
|
|
100
|
+
) -> None:
|
|
101
|
+
attributes = dict(properties)
|
|
102
|
+
|
|
103
|
+
if template is not None:
|
|
104
|
+
attributes["{OriginalFormat}"] = template
|
|
105
|
+
|
|
106
|
+
if exception is not None:
|
|
107
|
+
exception_type = type(exception)
|
|
108
|
+
attributes["exception.type"] = (
|
|
109
|
+
exception_type.__module__ + "." + exception_type.__qualname__
|
|
110
|
+
if exception_type.__module__ != "builtins"
|
|
111
|
+
else exception_type.__qualname__)
|
|
112
|
+
if str(exception):
|
|
113
|
+
attributes["exception.message"] = str(exception)
|
|
114
|
+
attributes["exception.stacktrace"] = GraftCodeAppInsightsLogger._stack_trace(exception)
|
|
115
|
+
|
|
116
|
+
self._logger.emit(LogRecord(
|
|
117
|
+
timestamp=time.time_ns(),
|
|
118
|
+
context=otel_context.get_current(),
|
|
119
|
+
severity_number=_SEVERITIES[level],
|
|
120
|
+
severity_text=GraftCodeAppInsightsLogger._to_level_name(level),
|
|
121
|
+
body=message,
|
|
122
|
+
attributes=attributes))
|
|
123
|
+
|
|
124
|
+
def _write_to_console(
|
|
125
|
+
self,
|
|
126
|
+
level: LogsLevel,
|
|
127
|
+
exception: Optional[BaseException],
|
|
128
|
+
message: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
console: Optional[IO[str]] = self._console
|
|
131
|
+
if console is None:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if console is True:
|
|
135
|
+
console = sys.stdout
|
|
136
|
+
|
|
137
|
+
line = (_CONSOLE_LEVELS.get(level, "fail") + ": " + self._category + "[0]" + os.linesep
|
|
138
|
+
+ _CONSOLE_INDENT + message)
|
|
139
|
+
if exception is not None:
|
|
140
|
+
line += os.linesep + GraftCodeAppInsightsLogger._stack_trace(exception).strip()
|
|
141
|
+
|
|
142
|
+
with self._console_lock:
|
|
143
|
+
console.write(line + os.linesep)
|
|
144
|
+
console.flush()
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _stack_trace(exception: BaseException) -> str:
|
|
148
|
+
return "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _to_level_name(level: LogsLevel) -> str:
|
|
152
|
+
return "Information" if level == LogsLevel.Info else level.name
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, MutableMapping
|
|
4
|
+
|
|
5
|
+
from opentelemetry import context as otel_context
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
8
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
9
|
+
|
|
10
|
+
_CONTROLLER_SUFFIX = "Controller"
|
|
11
|
+
_PROPAGATOR = TraceContextTextMapPropagator()
|
|
12
|
+
|
|
13
|
+
Scope = MutableMapping[str, object]
|
|
14
|
+
Message = MutableMapping[str, object]
|
|
15
|
+
Receive = Callable[[], Awaitable[dict]]
|
|
16
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
17
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GraftCodeAppInsightsRequestTelemetryMiddleware:
|
|
21
|
+
CONTROLLER_ATTRIBUTE = "graft.controller"
|
|
22
|
+
|
|
23
|
+
def __init__(self, app: ASGIApp, telemetry: object) -> None:
|
|
24
|
+
if telemetry is None:
|
|
25
|
+
raise ValueError("telemetry must not be None")
|
|
26
|
+
|
|
27
|
+
self._app = app
|
|
28
|
+
self._telemetry = telemetry
|
|
29
|
+
|
|
30
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
31
|
+
if scope.get("type") != "http":
|
|
32
|
+
await self._app(scope, receive, send)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
method = str(scope.get("method", "GET"))
|
|
36
|
+
path = GraftCodeAppInsightsRequestTelemetryMiddleware._resolve_path(scope)
|
|
37
|
+
headers = {
|
|
38
|
+
name.decode("latin-1").lower(): value.decode("latin-1")
|
|
39
|
+
for name, value in scope.get("headers") or []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
parent = _PROPAGATOR.extract(headers, context=otel_context.get_current())
|
|
43
|
+
span = self._telemetry.tracer.start_span(
|
|
44
|
+
GraftCodeAppInsightsRequestTelemetryMiddleware.resolve_operation_name(
|
|
45
|
+
self._telemetry.service_name, method, path, None),
|
|
46
|
+
context=parent,
|
|
47
|
+
kind=SpanKind.SERVER)
|
|
48
|
+
|
|
49
|
+
if span.is_recording():
|
|
50
|
+
span.set_attribute("http.method", method)
|
|
51
|
+
span.set_attribute("http.target", path)
|
|
52
|
+
|
|
53
|
+
status: dict[str, object | None] = {"code": None}
|
|
54
|
+
|
|
55
|
+
async def send_with_status(message: Message) -> None:
|
|
56
|
+
if message.get("type") == "http.response.start":
|
|
57
|
+
status["code"] = message.get("status")
|
|
58
|
+
await send(message)
|
|
59
|
+
|
|
60
|
+
token = otel_context.attach(trace.set_span_in_context(span))
|
|
61
|
+
try:
|
|
62
|
+
await self._app(scope, receive, send_with_status)
|
|
63
|
+
|
|
64
|
+
code = int(status["code"]) if status["code"] is not None else 200
|
|
65
|
+
span.set_attribute("http.status_code", code)
|
|
66
|
+
if code >= 500:
|
|
67
|
+
span.set_status(Status(StatusCode.ERROR))
|
|
68
|
+
except Exception as exception:
|
|
69
|
+
span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
70
|
+
span.record_exception(exception)
|
|
71
|
+
raise
|
|
72
|
+
finally:
|
|
73
|
+
controller = GraftCodeAppInsightsRequestTelemetryMiddleware.resolve_controller_name(scope)
|
|
74
|
+
if controller is not None:
|
|
75
|
+
span.update_name(GraftCodeAppInsightsRequestTelemetryMiddleware.resolve_operation_name(
|
|
76
|
+
self._telemetry.service_name, method, path, controller))
|
|
77
|
+
|
|
78
|
+
otel_context.detach(token)
|
|
79
|
+
span.end()
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def resolve_operation_name(service_name: str, method: str, path: str | None, controller: str | None) -> str:
|
|
83
|
+
if controller is not None and controller.strip():
|
|
84
|
+
return service_name + "." + controller
|
|
85
|
+
|
|
86
|
+
return service_name + " " + method + " " + (path or "/")
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def resolve_controller_name(scope: Scope) -> str | None:
|
|
90
|
+
state = scope.get("state")
|
|
91
|
+
explicit = state.get(GraftCodeAppInsightsRequestTelemetryMiddleware.CONTROLLER_ATTRIBUTE) \
|
|
92
|
+
if isinstance(state, dict) else None
|
|
93
|
+
if isinstance(explicit, str) and explicit.strip():
|
|
94
|
+
return explicit
|
|
95
|
+
|
|
96
|
+
owner = GraftCodeAppInsightsRequestTelemetryMiddleware._owner_name(scope.get("endpoint"))
|
|
97
|
+
if owner is None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if owner.endswith(_CONTROLLER_SUFFIX) and len(owner) > len(_CONTROLLER_SUFFIX):
|
|
101
|
+
return owner[:-len(_CONTROLLER_SUFFIX)]
|
|
102
|
+
return owner
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _owner_name(endpoint: object) -> str | None:
|
|
106
|
+
if endpoint is None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
bound_to = getattr(endpoint, "__self__", None)
|
|
110
|
+
if bound_to is not None:
|
|
111
|
+
return (bound_to if isinstance(bound_to, type) else type(bound_to)).__name__
|
|
112
|
+
|
|
113
|
+
qualified = getattr(endpoint, "__qualname__", "")
|
|
114
|
+
parts = [part for part in qualified.split(".") if part != "<locals>"]
|
|
115
|
+
return parts[-2] if len(parts) >= 2 else None
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _resolve_path(scope: Scope) -> str:
|
|
119
|
+
path = str(scope.get("path") or "/")
|
|
120
|
+
root_path = str(scope.get("root_path") or "")
|
|
121
|
+
if root_path and path.startswith(root_path):
|
|
122
|
+
path = path[len(root_path):]
|
|
123
|
+
return path or "/"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
4
|
+
|
|
5
|
+
_NULL_VALUE = "(null)"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessageTemplate:
|
|
9
|
+
|
|
10
|
+
@staticmethod
|
|
11
|
+
def render(template: Optional[str], args: Optional[Sequence[Any]], properties: Dict[str, str]) -> str:
|
|
12
|
+
if template is None:
|
|
13
|
+
return ""
|
|
14
|
+
|
|
15
|
+
values = args or ()
|
|
16
|
+
builder: List[str] = []
|
|
17
|
+
arg_index = 0
|
|
18
|
+
length = len(template)
|
|
19
|
+
i = 0
|
|
20
|
+
|
|
21
|
+
while i < length:
|
|
22
|
+
character = template[i]
|
|
23
|
+
|
|
24
|
+
if character in "{}" and i + 1 < length and template[i + 1] == character:
|
|
25
|
+
builder.append(character)
|
|
26
|
+
i += 2
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
closing_index = template.find("}", i + 1) if character == "{" else -1
|
|
30
|
+
if closing_index < 0 or arg_index >= len(values):
|
|
31
|
+
builder.append(character)
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
value = MessageTemplate._format(values[arg_index])
|
|
36
|
+
arg_index += 1
|
|
37
|
+
builder.append(value)
|
|
38
|
+
|
|
39
|
+
name = MessageTemplate._property_name(template[i + 1:closing_index])
|
|
40
|
+
if name:
|
|
41
|
+
properties[name] = value
|
|
42
|
+
|
|
43
|
+
i = closing_index + 1
|
|
44
|
+
|
|
45
|
+
return "".join(builder)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _property_name(placeholder: str) -> str:
|
|
49
|
+
end = len(placeholder)
|
|
50
|
+
for index, character in enumerate(placeholder):
|
|
51
|
+
if character in ",:":
|
|
52
|
+
end = index
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
name = placeholder[:end].strip()
|
|
56
|
+
if name.startswith("@") or name.startswith("$"):
|
|
57
|
+
name = name[1:]
|
|
58
|
+
|
|
59
|
+
return name
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _format(value: Any) -> str:
|
|
63
|
+
if value is None:
|
|
64
|
+
return _NULL_VALUE
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return str(value)
|
|
68
|
+
except Exception:
|
|
69
|
+
value_type = type(value)
|
|
70
|
+
return value_type.__module__ + "." + value_type.__qualname__
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from opentelemetry.context import Context
|
|
8
|
+
from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
|
|
9
|
+
from opentelemetry.trace import Link, SpanKind
|
|
10
|
+
from opentelemetry.trace.span import TraceState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimitedSampler(Sampler):
|
|
14
|
+
|
|
15
|
+
def __init__(self, traces_per_second: float) -> None:
|
|
16
|
+
self._traces_per_second = traces_per_second
|
|
17
|
+
self._capacity = max(1.0, traces_per_second)
|
|
18
|
+
self._tokens = self._capacity
|
|
19
|
+
self._last_refill = time.monotonic()
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def should_sample(
|
|
23
|
+
self,
|
|
24
|
+
parent_context: Optional[Context],
|
|
25
|
+
trace_id: int,
|
|
26
|
+
name: str,
|
|
27
|
+
kind: Optional[SpanKind] = None,
|
|
28
|
+
attributes: Optional[dict] = None,
|
|
29
|
+
links: Optional[tuple[Link, ...]] = None,
|
|
30
|
+
trace_state: Optional[TraceState] = None,
|
|
31
|
+
) -> SamplingResult:
|
|
32
|
+
decision = Decision.RECORD_AND_SAMPLE if self._try_acquire() else Decision.DROP
|
|
33
|
+
return SamplingResult(decision, attributes if decision == Decision.RECORD_AND_SAMPLE else None, trace_state)
|
|
34
|
+
|
|
35
|
+
def get_description(self) -> str:
|
|
36
|
+
return "RateLimitedSampler{" + str(self._traces_per_second) + "}"
|
|
37
|
+
|
|
38
|
+
def _try_acquire(self) -> bool:
|
|
39
|
+
with self._lock:
|
|
40
|
+
now = time.monotonic()
|
|
41
|
+
self._tokens = min(self._capacity, self._tokens + (now - self._last_refill) * self._traces_per_second)
|
|
42
|
+
self._last_refill = now
|
|
43
|
+
|
|
44
|
+
if self._tokens < 1.0:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
self._tokens -= 1.0
|
|
48
|
+
return True
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnector import GraftCodeAppInsightsConnector
|
|
2
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnectorOptions import GraftCodeAppInsightsConnectorOptions
|
|
3
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsConnectorPipeline import GraftCodeAppInsightsConnectorPipeline
|
|
4
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsLogger import GraftCodeAppInsightsLogger
|
|
5
|
+
from graftcode_telemetry_appinsights.GraftCodeAppInsightsRequestTelemetryMiddleware import (
|
|
6
|
+
GraftCodeAppInsightsRequestTelemetryMiddleware,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GraftCodeAppInsightsConnector",
|
|
11
|
+
"GraftCodeAppInsightsConnectorOptions",
|
|
12
|
+
"GraftCodeAppInsightsConnectorPipeline",
|
|
13
|
+
"GraftCodeAppInsightsLogger",
|
|
14
|
+
"GraftCodeAppInsightsRequestTelemetryMiddleware",
|
|
15
|
+
]
|
|
@@ -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,12 @@
|
|
|
1
|
+
graftcode_telemetry_appinsights/GraftCodeAppInsightsConnector.py,sha256=8KzytiE59VTHEN--003tiHZgwLkIvM9Y0g1TPy6Ngwg,1226
|
|
2
|
+
graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorOptions.py,sha256=YDNV1-hXKifji2-X6OXqrj0-bQLYwdfkkrPaF99jLNw,1915
|
|
3
|
+
graftcode_telemetry_appinsights/GraftCodeAppInsightsConnectorPipeline.py,sha256=fXFIZW_rmPXOhJWjskvyg95h47iKxTcKFX85n4F8S2o,9863
|
|
4
|
+
graftcode_telemetry_appinsights/GraftCodeAppInsightsLogger.py,sha256=HgkzvPO2SpSWiOdBxTVxPCuYGxaGTJkbd-YZxIqZifI,5081
|
|
5
|
+
graftcode_telemetry_appinsights/GraftCodeAppInsightsRequestTelemetryMiddleware.py,sha256=ftFxIYOsp5fbrQKKoBi6oC8JAAQC6y9b5xSb4TVuK6Y,4872
|
|
6
|
+
graftcode_telemetry_appinsights/MessageTemplate.py,sha256=MoUoFl0lyxjJ4CQEOUY2OYkH2fHvIDYj6-fIoT7EFzo,1936
|
|
7
|
+
graftcode_telemetry_appinsights/RateLimitedSampler.py,sha256=SowLd5dn_soTMbHaLTkoD550uhJaNrRZ-WQh47CrVI4,1684
|
|
8
|
+
graftcode_telemetry_appinsights/__init__.py,sha256=ZcYdcsHaqLWtNRaxzpsu-qRdyfGuaz4Wzvg27AA46QU,816
|
|
9
|
+
graftcode_telemetry_appinsights-1.0.0.dist-info/METADATA,sha256=F_KujYE9-7rs8ZonZZ2Vmi_oG8NIeoUL7QGxs3ndwcI,6446
|
|
10
|
+
graftcode_telemetry_appinsights-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
graftcode_telemetry_appinsights-1.0.0.dist-info/top_level.txt,sha256=DzD7wxkOZAEyRFI75D6HWBwAV8fjGU3wFzOA7vG8BNY,32
|
|
12
|
+
graftcode_telemetry_appinsights-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graftcode_telemetry_appinsights
|