azpaddypy 0.3.9__py3-none-any.whl → 0.4.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.
azpaddypy/mgmt/logging.py CHANGED
@@ -1,904 +1,916 @@
1
- import logging
2
- import os
3
- import functools
4
- import time
5
- import asyncio
6
- import uuid
7
- from typing import Optional, Dict, Any, Union, Callable
8
- from datetime import datetime
9
- from azure.monitor.opentelemetry import configure_azure_monitor
10
- from opentelemetry import trace
11
- from opentelemetry.trace import Status, StatusCode, Span
12
- from opentelemetry import baggage
13
- from opentelemetry.context import Context
14
-
15
-
16
- class AzureLogger:
17
- """Azure-integrated logger with OpenTelemetry distributed tracing.
18
-
19
- Provides comprehensive logging with Azure Monitor integration, correlation
20
- tracking, baggage propagation, and automated function tracing for Azure
21
- applications with seamless local development support.
22
-
23
- CLOUD ROLE NAME INTEGRATION:
24
- The service_name parameter automatically sets the cloud role name for
25
- Application Insights. When multiple services emit telemetry to the same
26
- Application Insights resource, each service will appear as a separate
27
- node on the Application Map, enabling proper service topology visualization.
28
-
29
- CORRELATION ID AUTOMATION:
30
- The trace_function decorator automatically generates UUID4 correlation IDs
31
- when none are manually set, ensuring consistent distributed tracing across
32
- all function calls without requiring manual configuration.
33
-
34
- Supports all standard logging levels (debug, info, warning, error, exception,
35
- critical) with enhanced context including trace IDs, correlation IDs, and
36
- baggage propagation.
37
-
38
- Attributes:
39
- service_name: Service identifier for telemetry and cloud role name
40
- service_version: Service version for context
41
- connection_string: Application Insights connection string
42
- logger: Python logger instance
43
- tracer: OpenTelemetry tracer for spans
44
- """
45
-
46
- def __init__(
47
- self,
48
- service_name: str,
49
- service_version: str = "1.0.0",
50
- connection_string: Optional[str] = None,
51
- log_level: int = logging.INFO,
52
- enable_console_logging: bool = True,
53
- custom_resource_attributes: Optional[Dict[str, str]] = None,
54
- instrumentation_options: Optional[Dict[str, Any]] = None,
55
- cloud_role_name: Optional[str] = None,
56
- ):
57
- """Initialize Azure Logger with OpenTelemetry tracing.
58
-
59
- The service_name parameter automatically sets the cloud role name for
60
- Application Insights Application Map visualization. When multiple services
61
- emit telemetry to the same Application Insights resource, each service
62
- will appear as a separate node on the Application Map.
63
-
64
- Args:
65
- service_name: Service identifier for telemetry and cloud role name
66
- service_version: Service version for metadata
67
- connection_string: Application Insights connection string
68
- log_level: Python logging level (default: INFO)
69
- enable_console_logging: Enable console output for local development
70
- custom_resource_attributes: Additional OpenTelemetry resource attributes
71
- instrumentation_options: Azure Monitor instrumentation options
72
- cloud_role_name: Override cloud role name (defaults to service_name)
73
- """
74
- self.service_name = service_name
75
- self.service_version = service_version
76
- self.connection_string = connection_string or os.getenv(
77
- "APPLICATIONINSIGHTS_CONNECTION_STRING"
78
- )
79
-
80
- # Use explicit cloud role name or default to service name
81
- effective_cloud_role_name = cloud_role_name or service_name
82
- self.cloud_role_name = effective_cloud_role_name
83
-
84
- # Configure resource attributes
85
- # NOTE: service.name automatically maps to cloud role name in Application Insights
86
- resource_attributes = {
87
- "service.name": effective_cloud_role_name,
88
- "service.version": service_version,
89
- "service.instance.id": os.getenv("WEBSITE_INSTANCE_ID", "local"),
90
- }
91
-
92
- if custom_resource_attributes:
93
- resource_attributes.update(custom_resource_attributes)
94
-
95
- # Configure Azure Monitor if connection string available
96
- if self.connection_string:
97
- try:
98
- configure_azure_monitor(
99
- connection_string=self.connection_string,
100
- resource_attributes=resource_attributes,
101
- enable_live_metrics=True,
102
- instrumentation_options=instrumentation_options,
103
- )
104
- self._telemetry_enabled = True
105
- except Exception as e:
106
- print(f"Warning: Failed to configure Azure Monitor: {e}")
107
- self._telemetry_enabled = False
108
- else:
109
- self._telemetry_enabled = False
110
- print(
111
- "Warning: No Application Insights connection string found. Telemetry disabled."
112
- )
113
-
114
- # Configure Python logger
115
- self.logger = logging.getLogger(service_name)
116
- self.logger.setLevel(log_level)
117
- self.logger.handlers.clear()
118
-
119
- if enable_console_logging:
120
- self._setup_console_handler()
121
-
122
- # Initialize OpenTelemetry tracer and correlation context
123
- self.tracer = trace.get_tracer(__name__)
124
- self._correlation_id = None
125
-
126
- self.info(
127
- f"Azure Logger initialized for service '{service_name}' v{service_version} "
128
- f"(cloud role: '{effective_cloud_role_name}')"
129
- )
130
-
131
- def _setup_console_handler(self):
132
- """Configure console handler for local development."""
133
- console_handler = logging.StreamHandler()
134
- formatter = logging.Formatter(
135
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s - %(pathname)s:%(lineno)d"
136
- )
137
- console_handler.setFormatter(formatter)
138
- self.logger.addHandler(console_handler)
139
-
140
- def set_correlation_id(self, correlation_id: str):
141
- """Set correlation ID for request/transaction tracking.
142
-
143
- Manually sets the correlation ID that will be used for all subsequent
144
- tracing operations. This value takes precedence over auto-generated
145
- correlation IDs in the trace_function decorator.
146
-
147
- Args:
148
- correlation_id: Unique identifier for transaction correlation
149
-
150
- Note:
151
- If not set manually, the trace_function decorator will automatically
152
- generate a UUID4 correlation_id on first use.
153
- """
154
- self._correlation_id = correlation_id
155
-
156
- def get_correlation_id(self) -> Optional[str]:
157
- """Get current correlation ID.
158
-
159
- Returns the currently active correlation ID, whether manually set
160
- or automatically generated by the trace_function decorator.
161
-
162
- Returns:
163
- Current correlation ID if set (manual or auto-generated), otherwise None
164
- """
165
- return self._correlation_id
166
-
167
- def set_baggage(self, key: str, value: str) -> Context:
168
- """Set baggage item in OpenTelemetry context.
169
-
170
- Args:
171
- key: Baggage key
172
- value: Baggage value
173
-
174
- Returns:
175
- Updated context with baggage item
176
- """
177
- return baggage.set_baggage(key, value)
178
-
179
- def get_baggage(self, key: str) -> Optional[str]:
180
- """Get baggage item from current context.
181
-
182
- Args:
183
- key: Baggage key
184
-
185
- Returns:
186
- Baggage value if exists, otherwise None
187
- """
188
- return baggage.get_baggage(key)
189
-
190
- def get_all_baggage(self) -> Dict[str, str]:
191
- """Get all baggage items from current context.
192
-
193
- Returns:
194
- Dictionary of all baggage items
195
- """
196
- return dict(baggage.get_all())
197
-
198
- def _enhance_extra(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
199
- """Enrich log records with contextual information.
200
-
201
- Args:
202
- extra: Optional custom data dictionary
203
-
204
- Returns:
205
- Enhanced dictionary with service context, correlation ID, trace
206
- context, and baggage items, with built-in LogRecord attributes filtered out
207
- """
208
- # Define built-in LogRecord attributes that should not be overwritten
209
- reserved_attributes = {
210
- 'name', 'msg', 'args', 'levelname', 'levelno', 'pathname', 'filename',
211
- 'module', 'exc_info', 'exc_text', 'stack_info', 'lineno', 'funcName',
212
- 'created', 'msecs', 'relativeCreated', 'thread', 'threadName',
213
- 'processName', 'process', 'getMessage'
214
- }
215
-
216
- enhanced_extra = {
217
- "service_name": self.service_name,
218
- "service_version": self.service_version,
219
- "timestamp": datetime.utcnow().isoformat(),
220
- }
221
-
222
- if self._correlation_id:
223
- enhanced_extra["correlation_id"] = self._correlation_id
224
-
225
- # Add span context if available
226
- current_span = trace.get_current_span()
227
- if current_span and current_span.is_recording():
228
- span_context = current_span.get_span_context()
229
- enhanced_extra["trace_id"] = format(span_context.trace_id, "032x")
230
- enhanced_extra["span_id"] = format(span_context.span_id, "016x")
231
-
232
- # Add baggage items, filtering out any reserved attribute names
233
- baggage_items = self.get_all_baggage()
234
- if baggage_items:
235
- # Filter baggage items to avoid conflicts with LogRecord attributes
236
- filtered_baggage = {k: v for k, v in baggage_items.items() if k not in reserved_attributes}
237
- if filtered_baggage:
238
- enhanced_extra["baggage"] = filtered_baggage
239
-
240
- # Log warning if any baggage keys were filtered out
241
- filtered_baggage_keys = set(baggage_items.keys()) - set(filtered_baggage.keys())
242
- if filtered_baggage_keys:
243
- # Use the base logger directly to avoid infinite recursion
244
- self.logger.warning(
245
- f"Filtered out reserved LogRecord attributes from baggage: {filtered_baggage_keys}"
246
- )
247
-
248
- if isinstance(extra, dict):
249
- # Filter out any keys that would conflict with built-in LogRecord attributes
250
- filtered_extra = {k: v for k, v in extra.items() if k not in reserved_attributes}
251
- enhanced_extra.update(filtered_extra)
252
-
253
- # Log warning if any keys were filtered out
254
- filtered_keys = set(extra.keys()) - set(filtered_extra.keys())
255
- if filtered_keys:
256
- # Use the base logger directly to avoid infinite recursion
257
- self.logger.warning(
258
- f"Filtered out reserved LogRecord attributes from extra data: {filtered_keys}"
259
- )
260
-
261
- return enhanced_extra
262
-
263
- def debug(self, message: str, extra: Optional[Dict[str, Any]] = None):
264
- """Log debug message with enhanced context."""
265
- self.logger.debug(message, extra=self._enhance_extra(extra))
266
-
267
- def info(self, message: str, extra: Optional[Dict[str, Any]] = None):
268
- """Log info message with enhanced context."""
269
- self.logger.info(message, extra=self._enhance_extra(extra))
270
-
271
- def warning(self, message: str, extra: Optional[Dict[str, Any]] = None):
272
- """Log warning message with enhanced context."""
273
- self.logger.warning(message, extra=self._enhance_extra(extra))
274
-
275
- def error(
276
- self,
277
- message: str,
278
- extra: Optional[Dict[str, Any]] = None,
279
- exc_info: bool = True,
280
- ):
281
- """Log error message with enhanced context and exception info."""
282
- self.logger.error(message, extra=self._enhance_extra(extra), exc_info=exc_info)
283
-
284
- def exception(self, message: str, extra: Optional[Dict[str, Any]] = None):
285
- """Log exception message with enhanced context and automatic exception info.
286
-
287
- This method is a convenience method equivalent to calling error() with
288
- exc_info=True. It should typically be called only from exception handlers.
289
-
290
- Args:
291
- message: Exception message to log
292
- extra: Additional custom properties
293
- """
294
- self.logger.error(message, extra=self._enhance_extra(extra), exc_info=True)
295
-
296
- def critical(self, message: str, extra: Optional[Dict[str, Any]] = None):
297
- """Log critical message with enhanced context."""
298
- self.logger.critical(message, extra=self._enhance_extra(extra))
299
-
300
- def log_function_execution(
301
- self,
302
- function_name: str,
303
- duration_ms: float,
304
- success: bool = True,
305
- extra: Optional[Dict[str, Any]] = None,
306
- ):
307
- """Log function execution metrics for performance monitoring.
308
-
309
- Args:
310
- function_name: Name of executed function
311
- duration_ms: Execution duration in milliseconds
312
- success: Whether function executed successfully
313
- extra: Additional custom properties
314
- """
315
- log_data = {
316
- "function_name": function_name,
317
- "duration_ms": duration_ms,
318
- "success": success,
319
- "performance_category": "function_execution",
320
- }
321
-
322
- if extra:
323
- log_data.update(extra)
324
-
325
- message = f"Function '{function_name}' executed in {duration_ms:.2f}ms - {'SUCCESS' if success else 'FAILED'}"
326
-
327
- if success:
328
- self.info(message, extra=log_data)
329
- else:
330
- self.error(message, extra=log_data, exc_info=False)
331
-
332
- def log_request(
333
- self,
334
- method: str,
335
- url: str,
336
- status_code: int,
337
- duration_ms: float,
338
- extra: Optional[Dict[str, Any]] = None,
339
- ):
340
- """Log HTTP request with comprehensive details.
341
-
342
- Args:
343
- method: HTTP method (GET, POST, etc.)
344
- url: Request URL
345
- status_code: HTTP response status code
346
- duration_ms: Request duration in milliseconds
347
- extra: Additional custom properties
348
- """
349
- log_data = {
350
- "http_method": method,
351
- "http_url": str(url),
352
- "http_status_code": status_code,
353
- "duration_ms": duration_ms,
354
- "request_category": "http_request",
355
- }
356
-
357
- if extra:
358
- log_data.update(extra)
359
-
360
- # Determine log level and status based on status code
361
- if status_code < 400:
362
- log_level = logging.INFO
363
- status_text = "SUCCESS"
364
- elif status_code < 500:
365
- log_level = logging.WARNING
366
- status_text = "CLIENT_ERROR"
367
- else:
368
- log_level = logging.ERROR
369
- status_text = "SERVER_ERROR"
370
-
371
- message = (
372
- f"{method} {url} - {status_code} - {duration_ms:.2f}ms - {status_text}"
373
- )
374
- self.logger.log(log_level, message, extra=self._enhance_extra(log_data))
375
-
376
- def create_span(
377
- self,
378
- span_name: str,
379
- attributes: Optional[Dict[str, Union[str, int, float, bool]]] = None,
380
- ) -> Span:
381
- """Create OpenTelemetry span for distributed tracing.
382
-
383
- Args:
384
- span_name: Name for the span
385
- attributes: Initial span attributes
386
-
387
- Returns:
388
- OpenTelemetry span context manager
389
- """
390
- span = self.tracer.start_span(span_name)
391
-
392
- # Add default service attributes
393
- span.set_attribute("service.name", self.service_name)
394
- span.set_attribute("service.version", self.service_version)
395
-
396
- if self._correlation_id:
397
- span.set_attribute("correlation.id", self._correlation_id)
398
-
399
- # Add custom attributes
400
- if attributes:
401
- for key, value in attributes.items():
402
- span.set_attribute(key, value)
403
-
404
- return span
405
-
406
- def _setup_span_for_function_trace(
407
- self,
408
- span: Span,
409
- func: Callable,
410
- is_async: bool,
411
- log_args: bool,
412
- args: tuple,
413
- kwargs: dict,
414
- log_result: bool,
415
- log_execution: bool,
416
- ):
417
- """Configure span attributes for function tracing."""
418
- span.set_attribute("function.name", func.__name__)
419
- span.set_attribute("function.module", func.__module__)
420
- span.set_attribute("service.name", self.service_name)
421
- span.set_attribute("function.is_async", is_async)
422
-
423
- # Add decorator parameters as span attributes
424
- span.set_attribute("function.decorator.log_args", log_args)
425
- span.set_attribute("function.decorator.log_result", log_result)
426
- span.set_attribute("function.decorator.log_execution", log_execution)
427
-
428
- if self._correlation_id:
429
- span.set_attribute("correlation.id", self._correlation_id)
430
-
431
- if log_args:
432
- if args:
433
- span.set_attribute("function.args_count", len(args))
434
- # Add positional arguments as span attributes
435
- import inspect
436
- try:
437
- sig = inspect.signature(func)
438
- param_names = list(sig.parameters.keys())
439
- for i, arg_value in enumerate(args):
440
- param_name = param_names[i] if i < len(param_names) else f"arg_{i}"
441
- try:
442
- # Convert to string for safe serialization
443
- attr_value = str(arg_value)
444
- # Truncate if too long to avoid excessive data
445
- if len(attr_value) > 1000:
446
- attr_value = attr_value[:1000] + "..."
447
- span.set_attribute(f"function.arg.{param_name}", attr_value)
448
- except Exception:
449
- span.set_attribute(f"function.arg.{param_name}", "<non-serializable>")
450
- except Exception:
451
- # Fallback if signature inspection fails
452
- for i, arg_value in enumerate(args):
453
- try:
454
- attr_value = str(arg_value)
455
- if len(attr_value) > 1000:
456
- attr_value = attr_value[:1000] + "..."
457
- span.set_attribute(f"function.arg.{i}", attr_value)
458
- except Exception:
459
- span.set_attribute(f"function.arg.{i}", "<non-serializable>")
460
-
461
- if kwargs:
462
- span.set_attribute("function.kwargs_count", len(kwargs))
463
- # Add keyword arguments as span attributes
464
- for key, value in kwargs.items():
465
- try:
466
- attr_value = str(value)
467
- # Truncate if too long to avoid excessive data
468
- if len(attr_value) > 1000:
469
- attr_value = attr_value[:1000] + "..."
470
- span.set_attribute(f"function.kwarg.{key}", attr_value)
471
- except Exception:
472
- span.set_attribute(f"function.kwarg.{key}", "<non-serializable>")
473
-
474
- def _handle_function_success(
475
- self,
476
- span: Span,
477
- func: Callable,
478
- duration_ms: float,
479
- result: Any,
480
- log_result: bool,
481
- log_execution: bool,
482
- is_async: bool,
483
- args: tuple,
484
- kwargs: dict,
485
- ):
486
- """Handle successful function execution in tracing."""
487
- span.set_attribute("function.duration_ms", duration_ms)
488
- span.set_attribute("function.success", True)
489
- span.set_status(Status(StatusCode.OK))
490
-
491
- if log_result and result is not None:
492
- span.set_attribute("function.has_result", True)
493
- span.set_attribute("function.result_type", type(result).__name__)
494
-
495
- if log_execution:
496
- self.log_function_execution(
497
- func.__name__,
498
- duration_ms,
499
- True,
500
- {
501
- "args_count": len(args) if args else 0,
502
- "kwargs_count": len(kwargs) if kwargs else 0,
503
- "is_async": is_async,
504
- },
505
- )
506
-
507
- log_prefix = "Async function" if is_async else "Function"
508
- self.debug(f"{log_prefix} execution completed: {func.__name__}")
509
-
510
- def _handle_function_exception(
511
- self,
512
- span: Span,
513
- func: Callable,
514
- duration_ms: float,
515
- e: Exception,
516
- log_execution: bool,
517
- is_async: bool,
518
- ):
519
- """Handle failed function execution in tracing."""
520
- span.set_status(Status(StatusCode.ERROR, description=str(e)))
521
- span.record_exception(e)
522
- if log_execution:
523
- self.log_function_execution(
524
- function_name=func.__name__, duration_ms=duration_ms, success=False
525
- )
526
- self.error(
527
- f"Exception in {'async ' if is_async else ''}"
528
- f"function '{func.__name__}': {e}"
529
- )
530
-
531
- def trace_function(
532
- self,
533
- function_name: Optional[str] = None,
534
- log_execution: bool = True,
535
- log_args: bool = True,
536
- log_result: bool = False,
537
- ) -> Callable:
538
- """Trace function execution with OpenTelemetry.
539
-
540
- This decorator provides a simple way to instrument a function by automatically
541
- creating an OpenTelemetry span to trace its execution. It measures execution
542
- time, captures arguments, and records the result as attributes on the span.
543
- It supports both synchronous and asynchronous functions.
544
-
545
- A correlation ID is automatically managed. If one is not already set on the
546
- logger instance, a new UUID4 correlation ID will be generated and used for
547
- the function's trace, ensuring that all related logs and traces can be
548
- linked together.
549
-
550
- Args:
551
- function_name (Optional[str], optional):
552
- Specifies a custom name for the span. If not provided, the name of the
553
- decorated function is used. Defaults to None.
554
- log_execution (bool, optional):
555
- When True, a log message is emitted at the start and end of the function's
556
- execution, including the execution duration. This provides a clear
557
- record of the function's lifecycle. Defaults to True.
558
- log_args (bool, optional):
559
- When True, the arguments passed to the function are recorded as
560
- attributes on the span. This is useful for understanding the context
561
- of the function call. Sensitive arguments should be handled with care.
562
- Defaults to True.
563
- log_result (bool, optional):
564
- When True, the return value of the function is recorded as an attribute
565
- on the span. This should be used cautiously, as large or sensitive
566
- return values may be captured. Defaults to False.
567
-
568
- Returns:
569
- Callable: A decorator that can be applied to a function.
570
-
571
- Examples:
572
- Basic usage with default settings:
573
- Logs execution, arguments, but not the result.
574
-
575
- @logger.trace_function()
576
- def sample_function(param1, param2="default"):
577
- return "done"
578
-
579
- Customizing the span name:
580
- The span will be named "MyCustomTask" instead of "process_data".
581
-
582
- @logger.trace_function(function_name="MyCustomTask")
583
- def process_data(data):
584
- # processing logic
585
- return {"status": "processed"}
586
-
587
- Logging the result:
588
- Enable `log_result=True` to capture the function's return value.
589
-
590
- @logger.trace_function(log_result=True)
591
- def get_user_id(username):
592
- return 12345
593
-
594
- Disabling argument logging for sensitive data:
595
- If a function handles sensitive information, disable argument logging.
596
-
597
- @logger.trace_function(log_args=False)
598
- def process_payment(credit_card_details):
599
- # payment processing logic
600
- pass
601
-
602
- Tracing an asynchronous function:
603
- The decorator works with async functions without any extra configuration.
604
-
605
- @logger.trace_function(log_result=True)
606
- async def fetch_remote_data(url):
607
- # async data fetching
608
- return await http_get(url)
609
- """
610
-
611
- def decorator(func):
612
- # Determine if the function is async at decoration time
613
- is_async = asyncio.iscoroutinefunction(func)
614
-
615
- @functools.wraps(func)
616
- async def async_wrapper(*args, **kwargs):
617
- span_name = function_name or f"{func.__module__}.{func.__name__}"
618
- with self.tracer.start_as_current_span(span_name) as span:
619
- # Auto-generate correlation_id if not set - ensures consistent tracing
620
- # Manual correlation_ids take precedence over auto-generated ones
621
- if not self._correlation_id:
622
- self._correlation_id = str(uuid.uuid4())
623
-
624
- self._setup_span_for_function_trace(
625
- span, func, True, log_args, args, kwargs, log_result, log_execution
626
- )
627
- start_time = time.time()
628
- try:
629
- self.debug(
630
- f"Starting async function execution: {func.__name__}"
631
- )
632
- result = await func(*args, **kwargs)
633
- duration_ms = (time.time() - start_time) * 1000
634
- self._handle_function_success(
635
- span,
636
- func,
637
- duration_ms,
638
- result,
639
- log_result,
640
- log_execution,
641
- True,
642
- args,
643
- kwargs,
644
- )
645
- return result
646
- except Exception as e:
647
- duration_ms = (time.time() - start_time) * 1000
648
- self._handle_function_exception(
649
- span, func, duration_ms, e, log_execution, True
650
- )
651
- raise
652
-
653
- @functools.wraps(func)
654
- def sync_wrapper(*args, **kwargs):
655
- span_name = function_name or f"{func.__module__}.{func.__name__}"
656
- with self.tracer.start_as_current_span(span_name) as span:
657
- # Auto-generate correlation_id if not set - ensures consistent tracing
658
- # Manual correlation_ids take precedence over auto-generated ones
659
- if not self._correlation_id:
660
- self._correlation_id = str(uuid.uuid4())
661
-
662
- self._setup_span_for_function_trace(
663
- span, func, False, log_args, args, kwargs, log_result, log_execution
664
- )
665
- start_time = time.time()
666
- try:
667
- self.debug(f"Starting function execution: {func.__name__}")
668
- result = func(*args, **kwargs)
669
- duration_ms = (time.time() - start_time) * 1000
670
- self._handle_function_success(
671
- span,
672
- func,
673
- duration_ms,
674
- result,
675
- log_result,
676
- log_execution,
677
- False,
678
- args,
679
- kwargs,
680
- )
681
- return result
682
- except Exception as e:
683
- duration_ms = (time.time() - start_time) * 1000
684
- self._handle_function_exception(
685
- span, func, duration_ms, e, log_execution, False
686
- )
687
- raise
688
-
689
- # Return appropriate wrapper based on function type
690
- if is_async:
691
- return async_wrapper
692
- return sync_wrapper
693
-
694
- return decorator
695
-
696
- def add_span_attributes(self, attributes: Dict[str, Union[str, int, float, bool]]):
697
- """Add attributes to current active span."""
698
- current_span = trace.get_current_span()
699
- if current_span and current_span.is_recording():
700
- for key, value in attributes.items():
701
- current_span.set_attribute(key, value)
702
-
703
- def add_span_event(self, name: str, attributes: Optional[Dict[str, Any]] = None):
704
- """Add event to current active span."""
705
- current_span = trace.get_current_span()
706
- if current_span and current_span.is_recording():
707
- event_attributes = attributes or {}
708
- if self._correlation_id:
709
- event_attributes["correlation_id"] = self._correlation_id
710
- current_span.add_event(name, event_attributes)
711
-
712
- def set_span_status(
713
- self, status_code: StatusCode, description: Optional[str] = None
714
- ):
715
- """Set status of current active span."""
716
- current_span = trace.get_current_span()
717
- if current_span and current_span.is_recording():
718
- current_span.set_status(Status(status_code, description))
719
-
720
- def log_with_span(
721
- self,
722
- span_name: str,
723
- message: str,
724
- level: int = logging.INFO,
725
- extra: Optional[Dict[str, Any]] = None,
726
- span_attributes: Optional[Dict[str, Union[str, int, float, bool]]] = None,
727
- ):
728
- """Log message within a span context.
729
-
730
- Args:
731
- span_name: Name for the span
732
- message: Log message
733
- level: Python logging level
734
- extra: Additional log properties
735
- span_attributes: Attributes to add to span
736
- """
737
- with self.tracer.start_as_current_span(span_name) as span:
738
- if span_attributes:
739
- for key, value in span_attributes.items():
740
- span.set_attribute(key, value)
741
-
742
- self.logger.log(level, message, extra=self._enhance_extra(extra))
743
-
744
- def log_dependency(
745
- self,
746
- dependency_type: str,
747
- name: str,
748
- command: str,
749
- success: bool,
750
- duration_ms: float,
751
- extra: Optional[Dict[str, Any]] = None,
752
- ):
753
- """Log external dependency calls for monitoring.
754
-
755
- Args:
756
- dependency_type: Type of dependency (SQL, HTTP, etc.)
757
- name: Dependency identifier
758
- command: Command/query executed
759
- success: Whether call was successful
760
- duration_ms: Call duration in milliseconds
761
- extra: Additional properties
762
- """
763
- log_data = {
764
- "dependency_type": dependency_type,
765
- "dependency_name": name,
766
- "dependency_command": command,
767
- "dependency_success": success,
768
- "duration_ms": duration_ms,
769
- "category": "dependency_call",
770
- }
771
-
772
- if extra:
773
- log_data.update(extra)
774
-
775
- log_level = logging.INFO if success else logging.ERROR
776
- status = "SUCCESS" if success else "FAILED"
777
- message = f"Dependency call: {dependency_type}:{name} - {duration_ms:.2f}ms - {status}"
778
-
779
- self.logger.log(log_level, message, extra=self._enhance_extra(log_data))
780
-
781
- def flush(self):
782
- """Flush pending telemetry data."""
783
- if self._telemetry_enabled:
784
- try:
785
- from opentelemetry.sdk.trace import TracerProvider
786
-
787
- tracer_provider = trace.get_tracer_provider()
788
- if hasattr(tracer_provider, "force_flush"):
789
- tracer_provider.force_flush(timeout_millis=5000)
790
- except Exception as e:
791
- self.warning(f"Failed to flush telemetry: {e}")
792
-
793
-
794
- # Factory functions with logger caching
795
- _loggers: Dict[Any, "AzureLogger"] = {}
796
-
797
-
798
- def create_app_logger(
799
- service_name: str,
800
- service_version: str = "1.0.0",
801
- connection_string: Optional[str] = None,
802
- log_level: int = logging.INFO,
803
- enable_console_logging: bool = True,
804
- custom_resource_attributes: Optional[Dict[str, str]] = None,
805
- instrumentation_options: Optional[Dict[str, Any]] = None,
806
- cloud_role_name: Optional[str] = None,
807
- ) -> AzureLogger:
808
- """Create cached AzureLogger instance for applications.
809
-
810
- Returns existing logger if one with same configuration exists.
811
- The service_name automatically becomes the cloud role name in Application Insights
812
- unless explicitly overridden with cloud_role_name parameter.
813
-
814
- Args:
815
- service_name: Service identifier for telemetry and cloud role name
816
- service_version: Service version for metadata
817
- connection_string: Application Insights connection string
818
- log_level: Python logging level
819
- enable_console_logging: Enable console output
820
- custom_resource_attributes: Additional OpenTelemetry resource attributes
821
- instrumentation_options: Azure Monitor instrumentation options
822
- cloud_role_name: Override cloud role name (defaults to service_name)
823
-
824
- Returns:
825
- Configured AzureLogger instance
826
- """
827
- resolved_connection_string = connection_string or os.getenv(
828
- "APPLICATIONINSIGHTS_CONNECTION_STRING"
829
- )
830
-
831
- attr_items = (
832
- tuple(sorted(custom_resource_attributes.items()))
833
- if custom_resource_attributes
834
- else None
835
- )
836
-
837
- params_key = (
838
- service_name,
839
- service_version,
840
- resolved_connection_string,
841
- log_level,
842
- enable_console_logging,
843
- attr_items,
844
- cloud_role_name,
845
- )
846
-
847
- if params_key in _loggers:
848
- return _loggers[params_key]
849
-
850
- logger = AzureLogger(
851
- service_name=service_name,
852
- service_version=service_version,
853
- connection_string=connection_string,
854
- log_level=log_level,
855
- enable_console_logging=enable_console_logging,
856
- custom_resource_attributes=custom_resource_attributes,
857
- instrumentation_options=instrumentation_options,
858
- cloud_role_name=cloud_role_name,
859
- )
860
- _loggers[params_key] = logger
861
- return logger
862
-
863
-
864
- def create_function_logger(
865
- function_app_name: str,
866
- function_name: str,
867
- service_version: str = "1.0.0",
868
- connection_string: Optional[str] = None,
869
- instrumentation_options: Optional[Dict[str, Any]] = None,
870
- cloud_role_name: Optional[str] = None,
871
- ) -> AzureLogger:
872
- """Create AzureLogger optimized for Azure Functions.
873
-
874
- Automatically creates cloud role name in the format '{function_app_name}.{function_name}'
875
- unless explicitly overridden. This ensures each function appears as a separate
876
- component in the Application Insights Application Map.
877
-
878
- Args:
879
- function_app_name: Azure Function App name
880
- function_name: Specific function name
881
- service_version: Service version for metadata
882
- connection_string: Application Insights connection string
883
- instrumentation_options: Azure Monitor instrumentation options
884
- cloud_role_name: Override cloud role name (defaults to '{function_app_name}.{function_name}')
885
-
886
- Returns:
887
- Configured AzureLogger with Azure Functions context
888
- """
889
- custom_attributes = {
890
- "azure.function.app": function_app_name,
891
- "azure.function.name": function_name,
892
- "azure.resource.type": "function",
893
- }
894
-
895
- default_service_name = f"{function_app_name}.{function_name}"
896
-
897
- return create_app_logger(
898
- service_name=default_service_name,
899
- service_version=service_version,
900
- connection_string=connection_string,
901
- custom_resource_attributes=custom_attributes,
902
- instrumentation_options=instrumentation_options,
903
- cloud_role_name=cloud_role_name,
904
- )
1
+ import logging
2
+ import os
3
+ import functools
4
+ import time
5
+ import asyncio
6
+ import uuid
7
+ from typing import Optional, Dict, Any, Union, Callable
8
+ from datetime import datetime
9
+ from azure.monitor.opentelemetry import configure_azure_monitor
10
+ from opentelemetry import trace
11
+ from opentelemetry.trace import Status, StatusCode, Span
12
+ from opentelemetry import baggage
13
+ from opentelemetry.context import Context
14
+
15
+
16
+ class AzureLogger:
17
+ """Azure-integrated logger with OpenTelemetry distributed tracing.
18
+
19
+ Provides comprehensive logging with Azure Monitor integration, correlation
20
+ tracking, baggage propagation, and automated function tracing for Azure
21
+ applications with seamless local development support.
22
+
23
+ CLOUD ROLE NAME INTEGRATION:
24
+ The service_name parameter automatically sets the cloud role name for
25
+ Application Insights. When multiple services emit telemetry to the same
26
+ Application Insights resource, each service will appear as a separate
27
+ node on the Application Map, enabling proper service topology visualization.
28
+
29
+ CORRELATION ID AUTOMATION:
30
+ The trace_function decorator automatically generates UUID4 correlation IDs
31
+ when none are manually set, ensuring consistent distributed tracing across
32
+ all function calls without requiring manual configuration.
33
+
34
+ Supports all standard logging levels (debug, info, warning, error, exception,
35
+ critical) with enhanced context including trace IDs, correlation IDs, and
36
+ baggage propagation.
37
+
38
+ Attributes:
39
+ service_name: Service identifier for telemetry and cloud role name
40
+ service_version: Service version for context
41
+ connection_string: Application Insights connection string
42
+ logger: Python logger instance
43
+ tracer: OpenTelemetry tracer for spans
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ service_name: str,
49
+ service_version: str = "1.0.0",
50
+ connection_string: Optional[str] = None,
51
+ log_level: int = logging.INFO,
52
+ enable_console_logging: bool = True,
53
+ custom_resource_attributes: Optional[Dict[str, str]] = None,
54
+ instrumentation_options: Optional[Dict[str, Any]] = None,
55
+ cloud_role_name: Optional[str] = None,
56
+ ):
57
+ """Initialize Azure Logger with OpenTelemetry tracing.
58
+
59
+ The service_name parameter automatically sets the cloud role name for
60
+ Application Insights Application Map visualization. When multiple services
61
+ emit telemetry to the same Application Insights resource, each service
62
+ will appear as a separate node on the Application Map.
63
+
64
+ Args:
65
+ service_name: Service identifier for telemetry and cloud role name
66
+ service_version: Service version for metadata
67
+ connection_string: Application Insights connection string
68
+ log_level: Python logging level (default: INFO)
69
+ enable_console_logging: Enable console output for local development
70
+ custom_resource_attributes: Additional OpenTelemetry resource attributes
71
+ instrumentation_options: Azure Monitor instrumentation options
72
+ cloud_role_name: Override cloud role name (defaults to service_name)
73
+ """
74
+ self.service_name = service_name
75
+ self.service_version = service_version
76
+ self.connection_string = connection_string or os.getenv(
77
+ "APPLICATIONINSIGHTS_CONNECTION_STRING"
78
+ )
79
+
80
+ # Use explicit cloud role name or default to service name
81
+ effective_cloud_role_name = cloud_role_name or service_name
82
+ self.cloud_role_name = effective_cloud_role_name
83
+
84
+ # Configure resource attributes
85
+ # NOTE: service.name automatically maps to cloud role name in Application Insights
86
+ resource_attributes = {
87
+ "service.name": effective_cloud_role_name,
88
+ "service.version": service_version,
89
+ "service.instance.id": os.getenv("WEBSITE_INSTANCE_ID", "local"),
90
+ }
91
+
92
+ if custom_resource_attributes:
93
+ resource_attributes.update(custom_resource_attributes)
94
+
95
+ # Configure Azure Monitor if connection string available
96
+ if self.connection_string:
97
+ try:
98
+ configure_azure_monitor(
99
+ connection_string=self.connection_string,
100
+ resource_attributes=resource_attributes,
101
+ enable_live_metrics=True,
102
+ instrumentation_options=instrumentation_options,
103
+ )
104
+ self._telemetry_enabled = True
105
+ except Exception as e:
106
+ print(f"Warning: Failed to configure Azure Monitor: {e}")
107
+ self._telemetry_enabled = False
108
+ else:
109
+ self._telemetry_enabled = False
110
+ print(
111
+ "Warning: No Application Insights connection string found. Telemetry disabled."
112
+ )
113
+
114
+ # Configure Python logger
115
+ self.logger = logging.getLogger(service_name)
116
+ self.logger.setLevel(log_level)
117
+ self.logger.handlers.clear()
118
+
119
+ if enable_console_logging:
120
+ self._setup_console_handler()
121
+
122
+ # Initialize OpenTelemetry tracer and correlation context
123
+ self.tracer = trace.get_tracer(__name__)
124
+ self._correlation_id = None
125
+
126
+ self.info(
127
+ f"Azure Logger initialized for service '{service_name}' v{service_version} "
128
+ f"(cloud role: '{effective_cloud_role_name}')"
129
+ )
130
+
131
+ def _setup_console_handler(self):
132
+ """Configure console handler for local development."""
133
+ console_handler = logging.StreamHandler()
134
+ formatter = logging.Formatter(
135
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s - %(pathname)s:%(lineno)d"
136
+ )
137
+ console_handler.setFormatter(formatter)
138
+ self.logger.addHandler(console_handler)
139
+
140
+ def set_correlation_id(self, correlation_id: str):
141
+ """Set correlation ID for request/transaction tracking.
142
+
143
+ Manually sets the correlation ID that will be used for all subsequent
144
+ tracing operations. This value takes precedence over auto-generated
145
+ correlation IDs in the trace_function decorator.
146
+
147
+ Args:
148
+ correlation_id: Unique identifier for transaction correlation
149
+
150
+ Note:
151
+ If not set manually, the trace_function decorator will automatically
152
+ generate a UUID4 correlation_id on first use.
153
+ """
154
+ self._correlation_id = correlation_id
155
+
156
+ def get_correlation_id(self) -> Optional[str]:
157
+ """Get current correlation ID.
158
+
159
+ Returns the currently active correlation ID, whether manually set
160
+ or automatically generated by the trace_function decorator.
161
+
162
+ Returns:
163
+ Current correlation ID if set (manual or auto-generated), otherwise None
164
+ """
165
+ return self._correlation_id
166
+
167
+ def set_baggage(self, key: str, value: str) -> Context:
168
+ """Set baggage item in OpenTelemetry context.
169
+
170
+ Args:
171
+ key: Baggage key
172
+ value: Baggage value
173
+
174
+ Returns:
175
+ Updated context with baggage item
176
+ """
177
+ return baggage.set_baggage(key, value)
178
+
179
+ def get_baggage(self, key: str) -> Optional[str]:
180
+ """Get baggage item from current context.
181
+
182
+ Args:
183
+ key: Baggage key
184
+
185
+ Returns:
186
+ Baggage value if exists, otherwise None
187
+ """
188
+ return baggage.get_baggage(key)
189
+
190
+ def get_all_baggage(self) -> Dict[str, str]:
191
+ """Get all baggage items from current context.
192
+
193
+ Returns:
194
+ Dictionary of all baggage items
195
+ """
196
+ return dict(baggage.get_all())
197
+
198
+ def _enhance_extra(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
199
+ """Enrich log records with contextual information.
200
+
201
+ Args:
202
+ extra: Optional custom data dictionary
203
+
204
+ Returns:
205
+ Enhanced dictionary with service context, correlation ID, trace
206
+ context, and baggage items, with built-in LogRecord attributes filtered out
207
+ """
208
+ # Define built-in LogRecord attributes that should not be overwritten
209
+ reserved_attributes = {
210
+ 'name', 'msg', 'args', 'levelname', 'levelno', 'pathname', 'filename',
211
+ 'module', 'exc_info', 'exc_text', 'stack_info', 'lineno', 'funcName',
212
+ 'created', 'msecs', 'relativeCreated', 'thread', 'threadName',
213
+ 'processName', 'process', 'getMessage'
214
+ }
215
+
216
+ enhanced_extra = {
217
+ "service_name": self.service_name,
218
+ "service_version": self.service_version,
219
+ "timestamp": datetime.utcnow().isoformat(),
220
+ }
221
+
222
+ if self._correlation_id:
223
+ enhanced_extra["correlation_id"] = self._correlation_id
224
+
225
+ # Add span context if available
226
+ current_span = trace.get_current_span()
227
+ if current_span and current_span.is_recording():
228
+ span_context = current_span.get_span_context()
229
+ enhanced_extra["trace_id"] = format(span_context.trace_id, "032x")
230
+ enhanced_extra["span_id"] = format(span_context.span_id, "016x")
231
+
232
+ # Add baggage items, filtering out any reserved attribute names
233
+ baggage_items = self.get_all_baggage()
234
+ if baggage_items:
235
+ # Filter baggage items to avoid conflicts with LogRecord attributes
236
+ filtered_baggage = {k: v for k, v in baggage_items.items() if k not in reserved_attributes}
237
+ if filtered_baggage:
238
+ enhanced_extra["baggage"] = filtered_baggage
239
+
240
+ # Log warning if any baggage keys were filtered out
241
+ filtered_baggage_keys = set(baggage_items.keys()) - set(filtered_baggage.keys())
242
+ if filtered_baggage_keys:
243
+ # Use the base logger directly to avoid infinite recursion
244
+ self.logger.warning(
245
+ f"Filtered out reserved LogRecord attributes from baggage: {filtered_baggage_keys}"
246
+ )
247
+
248
+ if isinstance(extra, dict):
249
+ # Filter out any keys that would conflict with built-in LogRecord attributes
250
+ filtered_extra = {k: v for k, v in extra.items() if k not in reserved_attributes}
251
+ enhanced_extra.update(filtered_extra)
252
+
253
+ # Log warning if any keys were filtered out
254
+ filtered_keys = set(extra.keys()) - set(filtered_extra.keys())
255
+ if filtered_keys:
256
+ # Use the base logger directly to avoid infinite recursion
257
+ self.logger.warning(
258
+ f"Filtered out reserved LogRecord attributes from extra data: {filtered_keys}"
259
+ )
260
+
261
+ return enhanced_extra
262
+
263
+ def debug(self, message: str, extra: Optional[Dict[str, Any]] = None):
264
+ """Log debug message with enhanced context."""
265
+ self.logger.debug(message, extra=self._enhance_extra(extra))
266
+
267
+ def info(self, message: str, extra: Optional[Dict[str, Any]] = None):
268
+ """Log info message with enhanced context."""
269
+ self.logger.info(message, extra=self._enhance_extra(extra))
270
+
271
+ def warning(self, message: str, extra: Optional[Dict[str, Any]] = None):
272
+ """Log warning message with enhanced context."""
273
+ self.logger.warning(message, extra=self._enhance_extra(extra))
274
+
275
+ def error(
276
+ self,
277
+ message: str,
278
+ extra: Optional[Dict[str, Any]] = None,
279
+ exc_info: bool = True,
280
+ ):
281
+ """Log error message with enhanced context and exception info."""
282
+ self.logger.error(message, extra=self._enhance_extra(extra), exc_info=exc_info)
283
+
284
+ def exception(self, message: str, extra: Optional[Dict[str, Any]] = None):
285
+ """Log exception message with enhanced context and automatic exception info.
286
+
287
+ This method is a convenience method equivalent to calling error() with
288
+ exc_info=True. It should typically be called only from exception handlers.
289
+
290
+ Args:
291
+ message: Exception message to log
292
+ extra: Additional custom properties
293
+ """
294
+ self.logger.error(message, extra=self._enhance_extra(extra), exc_info=True)
295
+
296
+ def critical(self, message: str, extra: Optional[Dict[str, Any]] = None):
297
+ """Log critical message with enhanced context."""
298
+ self.logger.critical(message, extra=self._enhance_extra(extra))
299
+
300
+ def log_function_execution(
301
+ self,
302
+ function_name: str,
303
+ duration_ms: float,
304
+ success: bool = True,
305
+ extra: Optional[Dict[str, Any]] = None,
306
+ ):
307
+ """Log function execution metrics for performance monitoring.
308
+
309
+ Args:
310
+ function_name: Name of executed function
311
+ duration_ms: Execution duration in milliseconds
312
+ success: Whether function executed successfully
313
+ extra: Additional custom properties
314
+ """
315
+ log_data = {
316
+ "function_name": function_name,
317
+ "duration_ms": duration_ms,
318
+ "success": success,
319
+ "performance_category": "function_execution",
320
+ }
321
+
322
+ if extra:
323
+ log_data.update(extra)
324
+
325
+ message = f"Function '{function_name}' executed in {duration_ms:.2f}ms - {'SUCCESS' if success else 'FAILED'}"
326
+
327
+ if success:
328
+ self.info(message, extra=log_data)
329
+ else:
330
+ self.error(message, extra=log_data, exc_info=False)
331
+
332
+ def log_request(
333
+ self,
334
+ method: str,
335
+ url: str,
336
+ status_code: int,
337
+ duration_ms: float,
338
+ extra: Optional[Dict[str, Any]] = None,
339
+ ):
340
+ """Log HTTP request with comprehensive details.
341
+
342
+ Args:
343
+ method: HTTP method (GET, POST, etc.)
344
+ url: Request URL
345
+ status_code: HTTP response status code
346
+ duration_ms: Request duration in milliseconds
347
+ extra: Additional custom properties
348
+ """
349
+ log_data = {
350
+ "http_method": method,
351
+ "http_url": str(url),
352
+ "http_status_code": status_code,
353
+ "duration_ms": duration_ms,
354
+ "request_category": "http_request",
355
+ }
356
+
357
+ if extra:
358
+ log_data.update(extra)
359
+
360
+ # Determine log level and status based on status code
361
+ if status_code < 400:
362
+ log_level = logging.INFO
363
+ status_text = "SUCCESS"
364
+ elif status_code < 500:
365
+ log_level = logging.WARNING
366
+ status_text = "CLIENT_ERROR"
367
+ else:
368
+ log_level = logging.ERROR
369
+ status_text = "SERVER_ERROR"
370
+
371
+ message = (
372
+ f"{method} {url} - {status_code} - {duration_ms:.2f}ms - {status_text}"
373
+ )
374
+ self.logger.log(log_level, message, extra=self._enhance_extra(log_data))
375
+
376
+ def create_span(
377
+ self,
378
+ span_name: str,
379
+ attributes: Optional[Dict[str, Union[str, int, float, bool]]] = None,
380
+ ) -> Span:
381
+ """Create OpenTelemetry span for distributed tracing.
382
+
383
+ Args:
384
+ span_name: Name for the span
385
+ attributes: Initial span attributes
386
+
387
+ Returns:
388
+ OpenTelemetry span context manager
389
+ """
390
+ span = self.tracer.start_span(span_name)
391
+
392
+ # Add default service attributes
393
+ span.set_attribute("service.name", self.service_name)
394
+ span.set_attribute("service.version", self.service_version)
395
+
396
+ if self._correlation_id:
397
+ span.set_attribute("correlation.id", self._correlation_id)
398
+
399
+ # Add custom attributes
400
+ if attributes:
401
+ for key, value in attributes.items():
402
+ span.set_attribute(key, value)
403
+
404
+ return span
405
+
406
+ def _setup_span_for_function_trace(
407
+ self,
408
+ span: Span,
409
+ func: Callable,
410
+ is_async: bool,
411
+ log_args: bool,
412
+ args: tuple,
413
+ kwargs: dict,
414
+ log_result: bool,
415
+ log_execution: bool,
416
+ ):
417
+ """Configure span attributes for function tracing."""
418
+ span.set_attribute("function.name", func.__name__)
419
+ span.set_attribute("function.module", func.__module__)
420
+ span.set_attribute("service.name", self.service_name)
421
+ span.set_attribute("function.is_async", is_async)
422
+
423
+ # Add decorator parameters as span attributes
424
+ span.set_attribute("function.decorator.log_args", log_args)
425
+ span.set_attribute("function.decorator.log_result", log_result)
426
+ span.set_attribute("function.decorator.log_execution", log_execution)
427
+
428
+ if self._correlation_id:
429
+ span.set_attribute("correlation.id", self._correlation_id)
430
+
431
+ if log_args:
432
+ if args:
433
+ span.set_attribute("function.args_count", len(args))
434
+ # Add positional arguments as span attributes
435
+ import inspect
436
+ try:
437
+ sig = inspect.signature(func)
438
+ param_names = list(sig.parameters.keys())
439
+ for i, arg_value in enumerate(args):
440
+ param_name = param_names[i] if i < len(param_names) else f"arg_{i}"
441
+ try:
442
+ # Convert to string for safe serialization
443
+ attr_value = str(arg_value)
444
+ # Truncate if too long to avoid excessive data
445
+ if len(attr_value) > 1000:
446
+ attr_value = attr_value[:1000] + "..."
447
+ span.set_attribute(f"function.arg.{param_name}", attr_value)
448
+ except Exception:
449
+ span.set_attribute(f"function.arg.{param_name}", "<non-serializable>")
450
+ except Exception:
451
+ # Fallback if signature inspection fails
452
+ for i, arg_value in enumerate(args):
453
+ try:
454
+ attr_value = str(arg_value)
455
+ if len(attr_value) > 1000:
456
+ attr_value = attr_value[:1000] + "..."
457
+ span.set_attribute(f"function.arg.{i}", attr_value)
458
+ except Exception:
459
+ span.set_attribute(f"function.arg.{i}", "<non-serializable>")
460
+
461
+ if kwargs:
462
+ span.set_attribute("function.kwargs_count", len(kwargs))
463
+ # Add keyword arguments as span attributes
464
+ for key, value in kwargs.items():
465
+ try:
466
+ attr_value = str(value)
467
+ # Truncate if too long to avoid excessive data
468
+ if len(attr_value) > 1000:
469
+ attr_value = attr_value[:1000] + "..."
470
+ span.set_attribute(f"function.kwarg.{key}", attr_value)
471
+ except Exception:
472
+ span.set_attribute(f"function.kwarg.{key}", "<non-serializable>")
473
+
474
+ def _handle_function_success(
475
+ self,
476
+ span: Span,
477
+ func: Callable,
478
+ duration_ms: float,
479
+ result: Any,
480
+ log_result: bool,
481
+ log_execution: bool,
482
+ is_async: bool,
483
+ args: tuple,
484
+ kwargs: dict,
485
+ ):
486
+ """Handle successful function execution in tracing."""
487
+ span.set_attribute("function.duration_ms", duration_ms)
488
+ span.set_attribute("function.success", True)
489
+ span.set_status(Status(StatusCode.OK))
490
+
491
+ if log_result and result is not None:
492
+ span.set_attribute("function.has_result", True)
493
+ span.set_attribute("function.result_type", type(result).__name__)
494
+ try:
495
+ # Convert to string for safe serialization
496
+ attr_value = str(result)
497
+ # Truncate if too long to avoid excessive data
498
+ if len(attr_value) > 1000:
499
+ attr_value = attr_value[:1000] + "..."
500
+ span.set_attribute("function.result", attr_value)
501
+ except Exception:
502
+ span.set_attribute("function.result", "<non-serializable>")
503
+
504
+ if log_execution:
505
+ self.log_function_execution(
506
+ func.__name__,
507
+ duration_ms,
508
+ True,
509
+ {
510
+ "args_count": len(args) if args else 0,
511
+ "kwargs_count": len(kwargs) if kwargs else 0,
512
+ "is_async": is_async,
513
+ },
514
+ )
515
+
516
+ log_prefix = "Async function" if is_async else "Function"
517
+ self.debug(f"{log_prefix} execution completed: {func.__name__}")
518
+
519
+ def _handle_function_exception(
520
+ self,
521
+ span: Span,
522
+ func: Callable,
523
+ duration_ms: float,
524
+ e: Exception,
525
+ log_execution: bool,
526
+ is_async: bool,
527
+ ):
528
+ """Handle failed function execution in tracing."""
529
+ span.set_status(Status(StatusCode.ERROR, description=str(e)))
530
+ span.record_exception(e)
531
+ if log_execution:
532
+ self.log_function_execution(
533
+ function_name=func.__name__, duration_ms=duration_ms, success=False
534
+ )
535
+ self.error(
536
+ f"Exception in {'async ' if is_async else ''}"
537
+ f"function '{func.__name__}': {e}"
538
+ )
539
+
540
+ def trace_function(
541
+ self,
542
+ function_name: Optional[str] = None,
543
+ log_execution: bool = True,
544
+ log_args: bool = True,
545
+ log_result: bool = False,
546
+ ) -> Callable:
547
+ """Trace function execution with OpenTelemetry.
548
+
549
+ This decorator provides a simple way to instrument a function by automatically
550
+ creating an OpenTelemetry span to trace its execution. It measures execution
551
+ time, captures arguments, and records the result as attributes on the span.
552
+ It supports both synchronous and asynchronous functions.
553
+
554
+ A correlation ID is automatically managed. If one is not already set on the
555
+ logger instance, a new UUID4 correlation ID will be generated and used for
556
+ the function's trace, ensuring that all related logs and traces can be
557
+ linked together.
558
+
559
+ Args:
560
+ function_name (Optional[str]):
561
+ Specifies a custom name for the span. If not provided, the name of the
562
+ decorated function is used. Defaults to None.
563
+ log_execution (bool):
564
+ When True, a log message is emitted at the start and end of the function's
565
+ execution, including the execution duration. This provides a clear
566
+ record of the function's lifecycle. Defaults to True.
567
+ log_args (bool):
568
+ When True, the arguments passed to the function are recorded as
569
+ attributes on the span. It captures argument names and their string
570
+ representations, truncating long values to 1000 characters.
571
+ This is useful for understanding the context of the function call.
572
+ Sensitive arguments should be handled with care. Defaults to True.
573
+ log_result (bool):
574
+ When True, the function's return value is recorded as an attribute
575
+ on the span. It captures the result's type and its string
576
+ representation, truncating long values to 1000 characters. This
577
+ should be used cautiously, as large or sensitive return values may
578
+ be captured. Defaults to False.
579
+
580
+ Returns:
581
+ Callable: A decorator that can be applied to a function.
582
+
583
+ Examples:
584
+ Basic usage with default settings:
585
+ Logs execution, arguments, but not the result.
586
+
587
+ @logger.trace_function()
588
+ def sample_function(param1, param2="default"):
589
+ return "done"
590
+
591
+ Customizing the span name:
592
+ The span will be named "MyCustomTask" instead of "process_data".
593
+
594
+ @logger.trace_function(function_name="MyCustomTask")
595
+ def process_data(data):
596
+ # processing logic
597
+ return {"status": "processed"}
598
+
599
+ Logging the result:
600
+ Enable `log_result=True` to capture the function's return value.
601
+
602
+ @logger.trace_function(log_result=True)
603
+ def get_user_id(username):
604
+ return 12345
605
+
606
+ Disabling argument logging for sensitive data:
607
+ If a function handles sensitive information, disable argument logging.
608
+
609
+ @logger.trace_function(log_args=False)
610
+ def process_payment(credit_card_details):
611
+ # payment processing logic
612
+ pass
613
+
614
+ Tracing an asynchronous function:
615
+ The decorator works with async functions without any extra configuration.
616
+
617
+ @logger.trace_function(log_result=True)
618
+ async def fetch_remote_data(url):
619
+ # async data fetching
620
+ return await http_get(url)
621
+ """
622
+
623
+ def decorator(func):
624
+ # Determine if the function is async at decoration time
625
+ is_async = asyncio.iscoroutinefunction(func)
626
+
627
+ @functools.wraps(func)
628
+ async def async_wrapper(*args, **kwargs):
629
+ span_name = function_name or f"{func.__module__}.{func.__name__}"
630
+ with self.tracer.start_as_current_span(span_name) as span:
631
+ # Auto-generate correlation_id if not set - ensures consistent tracing
632
+ # Manual correlation_ids take precedence over auto-generated ones
633
+ if not self._correlation_id:
634
+ self._correlation_id = str(uuid.uuid4())
635
+
636
+ self._setup_span_for_function_trace(
637
+ span, func, True, log_args, args, kwargs, log_result, log_execution
638
+ )
639
+ start_time = time.time()
640
+ try:
641
+ self.debug(
642
+ f"Starting async function execution: {func.__name__}"
643
+ )
644
+ result = await func(*args, **kwargs)
645
+ duration_ms = (time.time() - start_time) * 1000
646
+ self._handle_function_success(
647
+ span,
648
+ func,
649
+ duration_ms,
650
+ result,
651
+ log_result,
652
+ log_execution,
653
+ True,
654
+ args,
655
+ kwargs,
656
+ )
657
+ return result
658
+ except Exception as e:
659
+ duration_ms = (time.time() - start_time) * 1000
660
+ self._handle_function_exception(
661
+ span, func, duration_ms, e, log_execution, True
662
+ )
663
+ raise
664
+
665
+ @functools.wraps(func)
666
+ def sync_wrapper(*args, **kwargs):
667
+ span_name = function_name or f"{func.__module__}.{func.__name__}"
668
+ with self.tracer.start_as_current_span(span_name) as span:
669
+ # Auto-generate correlation_id if not set - ensures consistent tracing
670
+ # Manual correlation_ids take precedence over auto-generated ones
671
+ if not self._correlation_id:
672
+ self._correlation_id = str(uuid.uuid4())
673
+
674
+ self._setup_span_for_function_trace(
675
+ span, func, False, log_args, args, kwargs, log_result, log_execution
676
+ )
677
+ start_time = time.time()
678
+ try:
679
+ self.debug(f"Starting function execution: {func.__name__}")
680
+ result = func(*args, **kwargs)
681
+ duration_ms = (time.time() - start_time) * 1000
682
+ self._handle_function_success(
683
+ span,
684
+ func,
685
+ duration_ms,
686
+ result,
687
+ log_result,
688
+ log_execution,
689
+ False,
690
+ args,
691
+ kwargs,
692
+ )
693
+ return result
694
+ except Exception as e:
695
+ duration_ms = (time.time() - start_time) * 1000
696
+ self._handle_function_exception(
697
+ span, func, duration_ms, e, log_execution, False
698
+ )
699
+ raise
700
+
701
+ # Return appropriate wrapper based on function type
702
+ if is_async:
703
+ return async_wrapper
704
+ return sync_wrapper
705
+
706
+ return decorator
707
+
708
+ def add_span_attributes(self, attributes: Dict[str, Union[str, int, float, bool]]):
709
+ """Add attributes to current active span."""
710
+ current_span = trace.get_current_span()
711
+ if current_span and current_span.is_recording():
712
+ for key, value in attributes.items():
713
+ current_span.set_attribute(key, value)
714
+
715
+ def add_span_event(self, name: str, attributes: Optional[Dict[str, Any]] = None):
716
+ """Add event to current active span."""
717
+ current_span = trace.get_current_span()
718
+ if current_span and current_span.is_recording():
719
+ event_attributes = attributes or {}
720
+ if self._correlation_id:
721
+ event_attributes["correlation_id"] = self._correlation_id
722
+ current_span.add_event(name, event_attributes)
723
+
724
+ def set_span_status(
725
+ self, status_code: StatusCode, description: Optional[str] = None
726
+ ):
727
+ """Set status of current active span."""
728
+ current_span = trace.get_current_span()
729
+ if current_span and current_span.is_recording():
730
+ current_span.set_status(Status(status_code, description))
731
+
732
+ def log_with_span(
733
+ self,
734
+ span_name: str,
735
+ message: str,
736
+ level: int = logging.INFO,
737
+ extra: Optional[Dict[str, Any]] = None,
738
+ span_attributes: Optional[Dict[str, Union[str, int, float, bool]]] = None,
739
+ ):
740
+ """Log message within a span context.
741
+
742
+ Args:
743
+ span_name: Name for the span
744
+ message: Log message
745
+ level: Python logging level
746
+ extra: Additional log properties
747
+ span_attributes: Attributes to add to span
748
+ """
749
+ with self.tracer.start_as_current_span(span_name) as span:
750
+ if span_attributes:
751
+ for key, value in span_attributes.items():
752
+ span.set_attribute(key, value)
753
+
754
+ self.logger.log(level, message, extra=self._enhance_extra(extra))
755
+
756
+ def log_dependency(
757
+ self,
758
+ dependency_type: str,
759
+ name: str,
760
+ command: str,
761
+ success: bool,
762
+ duration_ms: float,
763
+ extra: Optional[Dict[str, Any]] = None,
764
+ ):
765
+ """Log external dependency calls for monitoring.
766
+
767
+ Args:
768
+ dependency_type: Type of dependency (SQL, HTTP, etc.)
769
+ name: Dependency identifier
770
+ command: Command/query executed
771
+ success: Whether call was successful
772
+ duration_ms: Call duration in milliseconds
773
+ extra: Additional properties
774
+ """
775
+ log_data = {
776
+ "dependency_type": dependency_type,
777
+ "dependency_name": name,
778
+ "dependency_command": command,
779
+ "dependency_success": success,
780
+ "duration_ms": duration_ms,
781
+ "category": "dependency_call",
782
+ }
783
+
784
+ if extra:
785
+ log_data.update(extra)
786
+
787
+ log_level = logging.INFO if success else logging.ERROR
788
+ status = "SUCCESS" if success else "FAILED"
789
+ message = f"Dependency call: {dependency_type}:{name} - {duration_ms:.2f}ms - {status}"
790
+
791
+ self.logger.log(log_level, message, extra=self._enhance_extra(log_data))
792
+
793
+ def flush(self):
794
+ """Flush pending telemetry data."""
795
+ if self._telemetry_enabled:
796
+ try:
797
+ from opentelemetry.sdk.trace import TracerProvider
798
+
799
+ tracer_provider = trace.get_tracer_provider()
800
+ if hasattr(tracer_provider, "force_flush"):
801
+ tracer_provider.force_flush(timeout_millis=5000)
802
+ except Exception as e:
803
+ self.warning(f"Failed to flush telemetry: {e}")
804
+
805
+
806
+ # Factory functions with logger caching
807
+ _loggers: Dict[Any, "AzureLogger"] = {}
808
+
809
+
810
+ def create_app_logger(
811
+ service_name: str,
812
+ service_version: str = "1.0.0",
813
+ connection_string: Optional[str] = None,
814
+ log_level: int = logging.INFO,
815
+ enable_console_logging: bool = True,
816
+ custom_resource_attributes: Optional[Dict[str, str]] = None,
817
+ instrumentation_options: Optional[Dict[str, Any]] = None,
818
+ cloud_role_name: Optional[str] = None,
819
+ ) -> AzureLogger:
820
+ """Create cached AzureLogger instance for applications.
821
+
822
+ Returns existing logger if one with same configuration exists.
823
+ The service_name automatically becomes the cloud role name in Application Insights
824
+ unless explicitly overridden with cloud_role_name parameter.
825
+
826
+ Args:
827
+ service_name: Service identifier for telemetry and cloud role name
828
+ service_version: Service version for metadata
829
+ connection_string: Application Insights connection string
830
+ log_level: Python logging level
831
+ enable_console_logging: Enable console output
832
+ custom_resource_attributes: Additional OpenTelemetry resource attributes
833
+ instrumentation_options: Azure Monitor instrumentation options
834
+ cloud_role_name: Override cloud role name (defaults to service_name)
835
+
836
+ Returns:
837
+ Configured AzureLogger instance
838
+ """
839
+ resolved_connection_string = connection_string or os.getenv(
840
+ "APPLICATIONINSIGHTS_CONNECTION_STRING"
841
+ )
842
+
843
+ attr_items = (
844
+ tuple(sorted(custom_resource_attributes.items()))
845
+ if custom_resource_attributes
846
+ else None
847
+ )
848
+
849
+ params_key = (
850
+ service_name,
851
+ service_version,
852
+ resolved_connection_string,
853
+ log_level,
854
+ enable_console_logging,
855
+ attr_items,
856
+ cloud_role_name,
857
+ )
858
+
859
+ if params_key in _loggers:
860
+ return _loggers[params_key]
861
+
862
+ logger = AzureLogger(
863
+ service_name=service_name,
864
+ service_version=service_version,
865
+ connection_string=connection_string,
866
+ log_level=log_level,
867
+ enable_console_logging=enable_console_logging,
868
+ custom_resource_attributes=custom_resource_attributes,
869
+ instrumentation_options=instrumentation_options,
870
+ cloud_role_name=cloud_role_name,
871
+ )
872
+ _loggers[params_key] = logger
873
+ return logger
874
+
875
+
876
+ def create_function_logger(
877
+ function_app_name: str,
878
+ function_name: str,
879
+ service_version: str = "1.0.0",
880
+ connection_string: Optional[str] = None,
881
+ instrumentation_options: Optional[Dict[str, Any]] = None,
882
+ cloud_role_name: Optional[str] = None,
883
+ ) -> AzureLogger:
884
+ """Create AzureLogger optimized for Azure Functions.
885
+
886
+ Automatically creates cloud role name in the format '{function_app_name}.{function_name}'
887
+ unless explicitly overridden. This ensures each function appears as a separate
888
+ component in the Application Insights Application Map.
889
+
890
+ Args:
891
+ function_app_name: Azure Function App name
892
+ function_name: Specific function name
893
+ service_version: Service version for metadata
894
+ connection_string: Application Insights connection string
895
+ instrumentation_options: Azure Monitor instrumentation options
896
+ cloud_role_name: Override cloud role name (defaults to '{function_app_name}.{function_name}')
897
+
898
+ Returns:
899
+ Configured AzureLogger with Azure Functions context
900
+ """
901
+ custom_attributes = {
902
+ "azure.function.app": function_app_name,
903
+ "azure.function.name": function_name,
904
+ "azure.resource.type": "function",
905
+ }
906
+
907
+ default_service_name = f"{function_app_name}.{function_name}"
908
+
909
+ return create_app_logger(
910
+ service_name=default_service_name,
911
+ service_version=service_version,
912
+ connection_string=connection_string,
913
+ custom_resource_attributes=custom_attributes,
914
+ instrumentation_options=instrumentation_options,
915
+ cloud_role_name=cloud_role_name,
916
+ )