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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ graftcode_telemetry_appinsights