monocle-apptrace 0.2.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of monocle-apptrace might be problematic. Click here for more details.

Files changed (98) hide show
  1. monocle_apptrace/__init__.py +1 -0
  2. monocle_apptrace/__main__.py +19 -0
  3. monocle_apptrace/exporters/aws/s3_exporter.py +50 -27
  4. monocle_apptrace/exporters/aws/s3_exporter_opendal.py +137 -0
  5. monocle_apptrace/exporters/azure/blob_exporter.py +30 -12
  6. monocle_apptrace/exporters/azure/blob_exporter_opendal.py +162 -0
  7. monocle_apptrace/exporters/base_exporter.py +19 -18
  8. monocle_apptrace/exporters/exporter_processor.py +128 -3
  9. monocle_apptrace/exporters/file_exporter.py +16 -0
  10. monocle_apptrace/exporters/monocle_exporters.py +48 -20
  11. monocle_apptrace/exporters/okahu/okahu_exporter.py +8 -6
  12. monocle_apptrace/instrumentation/__init__.py +1 -0
  13. monocle_apptrace/instrumentation/common/__init__.py +2 -0
  14. monocle_apptrace/instrumentation/common/constants.py +70 -0
  15. monocle_apptrace/instrumentation/common/instrumentor.py +362 -0
  16. monocle_apptrace/instrumentation/common/span_handler.py +220 -0
  17. monocle_apptrace/instrumentation/common/utils.py +356 -0
  18. monocle_apptrace/instrumentation/common/wrapper.py +92 -0
  19. monocle_apptrace/instrumentation/common/wrapper_method.py +72 -0
  20. monocle_apptrace/instrumentation/metamodel/__init__.py +0 -0
  21. monocle_apptrace/instrumentation/metamodel/botocore/__init__.py +0 -0
  22. monocle_apptrace/instrumentation/metamodel/botocore/_helper.py +95 -0
  23. monocle_apptrace/instrumentation/metamodel/botocore/entities/__init__.py +0 -0
  24. monocle_apptrace/instrumentation/metamodel/botocore/entities/inference.py +65 -0
  25. monocle_apptrace/instrumentation/metamodel/botocore/handlers/botocore_span_handler.py +26 -0
  26. monocle_apptrace/instrumentation/metamodel/botocore/methods.py +16 -0
  27. monocle_apptrace/instrumentation/metamodel/flask/__init__.py +0 -0
  28. monocle_apptrace/instrumentation/metamodel/flask/_helper.py +29 -0
  29. monocle_apptrace/instrumentation/metamodel/flask/methods.py +13 -0
  30. monocle_apptrace/instrumentation/metamodel/haystack/__init__.py +0 -0
  31. monocle_apptrace/instrumentation/metamodel/haystack/_helper.py +127 -0
  32. monocle_apptrace/instrumentation/metamodel/haystack/entities/__init__.py +0 -0
  33. monocle_apptrace/instrumentation/metamodel/haystack/entities/inference.py +76 -0
  34. monocle_apptrace/instrumentation/metamodel/haystack/entities/retrieval.py +61 -0
  35. monocle_apptrace/instrumentation/metamodel/haystack/methods.py +43 -0
  36. monocle_apptrace/instrumentation/metamodel/langchain/__init__.py +0 -0
  37. monocle_apptrace/instrumentation/metamodel/langchain/_helper.py +127 -0
  38. monocle_apptrace/instrumentation/metamodel/langchain/entities/__init__.py +0 -0
  39. monocle_apptrace/instrumentation/metamodel/langchain/entities/inference.py +72 -0
  40. monocle_apptrace/instrumentation/metamodel/langchain/entities/retrieval.py +58 -0
  41. monocle_apptrace/instrumentation/metamodel/langchain/methods.py +111 -0
  42. monocle_apptrace/instrumentation/metamodel/langgraph/__init__.py +0 -0
  43. monocle_apptrace/instrumentation/metamodel/langgraph/_helper.py +48 -0
  44. monocle_apptrace/instrumentation/metamodel/langgraph/entities/__init__.py +0 -0
  45. monocle_apptrace/instrumentation/metamodel/langgraph/entities/inference.py +56 -0
  46. monocle_apptrace/instrumentation/metamodel/langgraph/methods.py +14 -0
  47. monocle_apptrace/instrumentation/metamodel/llamaindex/__init__.py +0 -0
  48. monocle_apptrace/instrumentation/metamodel/llamaindex/_helper.py +172 -0
  49. monocle_apptrace/instrumentation/metamodel/llamaindex/entities/__init__.py +0 -0
  50. monocle_apptrace/instrumentation/metamodel/llamaindex/entities/agent.py +47 -0
  51. monocle_apptrace/instrumentation/metamodel/llamaindex/entities/inference.py +73 -0
  52. monocle_apptrace/instrumentation/metamodel/llamaindex/entities/retrieval.py +57 -0
  53. monocle_apptrace/instrumentation/metamodel/llamaindex/methods.py +101 -0
  54. monocle_apptrace/instrumentation/metamodel/openai/__init__.py +0 -0
  55. monocle_apptrace/instrumentation/metamodel/openai/_helper.py +112 -0
  56. monocle_apptrace/instrumentation/metamodel/openai/entities/__init__.py +0 -0
  57. monocle_apptrace/instrumentation/metamodel/openai/entities/inference.py +71 -0
  58. monocle_apptrace/instrumentation/metamodel/openai/entities/retrieval.py +43 -0
  59. monocle_apptrace/instrumentation/metamodel/openai/methods.py +45 -0
  60. monocle_apptrace/instrumentation/metamodel/requests/__init__.py +4 -0
  61. monocle_apptrace/instrumentation/metamodel/requests/_helper.py +31 -0
  62. monocle_apptrace/instrumentation/metamodel/requests/methods.py +12 -0
  63. {monocle_apptrace-0.2.0.dist-info → monocle_apptrace-0.3.0.dist-info}/METADATA +19 -2
  64. monocle_apptrace-0.3.0.dist-info/RECORD +68 -0
  65. {monocle_apptrace-0.2.0.dist-info → monocle_apptrace-0.3.0.dist-info}/WHEEL +1 -1
  66. monocle_apptrace/constants.py +0 -22
  67. monocle_apptrace/haystack/__init__.py +0 -9
  68. monocle_apptrace/haystack/wrap_node.py +0 -27
  69. monocle_apptrace/haystack/wrap_openai.py +0 -44
  70. monocle_apptrace/haystack/wrap_pipeline.py +0 -63
  71. monocle_apptrace/instrumentor.py +0 -121
  72. monocle_apptrace/langchain/__init__.py +0 -9
  73. monocle_apptrace/llamaindex/__init__.py +0 -16
  74. monocle_apptrace/metamodel/README.md +0 -47
  75. monocle_apptrace/metamodel/entities/README.md +0 -77
  76. monocle_apptrace/metamodel/entities/app_hosting_types.json +0 -29
  77. monocle_apptrace/metamodel/entities/entities.json +0 -49
  78. monocle_apptrace/metamodel/entities/inference_types.json +0 -33
  79. monocle_apptrace/metamodel/entities/model_types.json +0 -41
  80. monocle_apptrace/metamodel/entities/vector_store_types.json +0 -25
  81. monocle_apptrace/metamodel/entities/workflow_types.json +0 -22
  82. monocle_apptrace/metamodel/maps/attributes/inference/langchain_entities.json +0 -35
  83. monocle_apptrace/metamodel/maps/attributes/inference/llamaindex_entities.json +0 -35
  84. monocle_apptrace/metamodel/maps/attributes/retrieval/langchain_entities.json +0 -27
  85. monocle_apptrace/metamodel/maps/attributes/retrieval/llamaindex_entities.json +0 -27
  86. monocle_apptrace/metamodel/maps/haystack_methods.json +0 -25
  87. monocle_apptrace/metamodel/maps/langchain_methods.json +0 -129
  88. monocle_apptrace/metamodel/maps/llamaindex_methods.json +0 -74
  89. monocle_apptrace/metamodel/spans/README.md +0 -121
  90. monocle_apptrace/metamodel/spans/span_example.json +0 -140
  91. monocle_apptrace/metamodel/spans/span_format.json +0 -55
  92. monocle_apptrace/metamodel/spans/span_types.json +0 -16
  93. monocle_apptrace/utils.py +0 -172
  94. monocle_apptrace/wrap_common.py +0 -417
  95. monocle_apptrace/wrapper.py +0 -26
  96. monocle_apptrace-0.2.0.dist-info/RECORD +0 -44
  97. {monocle_apptrace-0.2.0.dist-info → monocle_apptrace-0.3.0.dist-info}/licenses/LICENSE +0 -0
  98. {monocle_apptrace-0.2.0.dist-info → monocle_apptrace-0.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1 @@
1
+ from .instrumentation import *
@@ -0,0 +1,19 @@
1
+ import sys, os
2
+ import runpy
3
+ from monocle_apptrace.instrumentation.common.instrumentor import setup_monocle_telemetry
4
+
5
+ def main():
6
+ if len(sys.argv) < 2 or not sys.argv[1].endswith(".py"):
7
+ print("Usage: python -m monocle_apptrace <your-main-module-file> <args>")
8
+ sys.exit(1)
9
+ file_name = os.path.basename(sys.argv[1])
10
+ workflow_name = file_name[:-3]
11
+ setup_monocle_telemetry(workflow_name=workflow_name)
12
+ sys.argv.pop(0)
13
+
14
+ try:
15
+ runpy.run_path(path_name=sys.argv[0], run_name="__main__")
16
+ except Exception as e:
17
+ print(e)
18
+ if __name__ == "__main__":
19
+ main()
@@ -6,43 +6,59 @@ import logging
6
6
  import asyncio
7
7
  import boto3
8
8
  from botocore.exceptions import ClientError
9
+ from botocore.exceptions import (
10
+ BotoCoreError,
11
+ ConnectionClosedError,
12
+ ConnectTimeoutError,
13
+ EndpointConnectionError,
14
+ ReadTimeoutError,
15
+ )
9
16
  from opentelemetry.sdk.trace import ReadableSpan
10
17
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
11
18
  from monocle_apptrace.exporters.base_exporter import SpanExporterBase
12
- from typing import Sequence
19
+ from monocle_apptrace.exporters.exporter_processor import ExportTaskProcessor
20
+ from typing import Sequence, Optional
13
21
  import json
14
22
  logger = logging.getLogger(__name__)
15
23
 
16
24
  class S3SpanExporter(SpanExporterBase):
17
- def __init__(self, bucket_name=None, region_name="us-east-1"):
25
+ def __init__(self, bucket_name=None, region_name=None, task_processor: Optional[ExportTaskProcessor] = None):
18
26
  super().__init__()
19
27
  # Use environment variables if credentials are not provided
20
- DEFAULT_FILE_PREFIX = "monocle_trace__"
28
+ DEFAULT_FILE_PREFIX = "monocle_trace_"
21
29
  DEFAULT_TIME_FORMAT = "%Y-%m-%d__%H.%M.%S"
22
30
  self.max_batch_size = 500
23
31
  self.export_interval = 1
24
- self.s3_client = boto3.client(
25
- 's3',
26
- aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
27
- aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'),
28
- region_name=region_name,
29
- )
32
+ if(os.getenv('MONOCLE_AWS_ACCESS_KEY_ID') and os.getenv('MONOCLE_AWS_SECRET_ACCESS_KEY')):
33
+ self.s3_client = boto3.client(
34
+ 's3',
35
+ aws_access_key_id=os.getenv('MONOCLE_AWS_ACCESS_KEY_ID'),
36
+ aws_secret_access_key=os.getenv('MONOCLE_AWS_SECRET_ACCESS_KEY'),
37
+ region_name=region_name,
38
+ )
39
+ else:
40
+ self.s3_client = boto3.client(
41
+ 's3',
42
+ aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
43
+ aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'),
44
+ region_name=region_name,
45
+ )
30
46
  self.bucket_name = bucket_name or os.getenv('MONOCLE_S3_BUCKET_NAME','default-bucket')
31
- self.file_prefix = DEFAULT_FILE_PREFIX
47
+ self.file_prefix = os.getenv('MONOCLE_S3_KEY_PREFIX', DEFAULT_FILE_PREFIX)
32
48
  self.time_format = DEFAULT_TIME_FORMAT
33
49
  self.export_queue = []
34
50
  self.last_export_time = time.time()
51
+ self.task_processor = task_processor
52
+ if self.task_processor is not None:
53
+ self.task_processor.start()
35
54
 
36
55
  # Check if bucket exists or create it
37
56
  if not self.__bucket_exists(self.bucket_name):
38
57
  try:
39
- if region_name == "us-east-1":
40
- self.s3_client.create_bucket(Bucket=self.bucket_name)
41
- else:
42
- self.s3_client.create_bucket(
43
- Bucket=self.bucket_name,
44
- CreateBucketConfiguration={'LocationConstraint': region_name}
45
- )
58
+ self.s3_client.create_bucket(
59
+ Bucket=self.bucket_name,
60
+ CreateBucketConfiguration={'LocationConstraint': region_name}
61
+ )
46
62
  logger.info(f"Bucket {self.bucket_name} created successfully.")
47
63
  except ClientError as e:
48
64
  logger.error(f"Error creating bucket {self.bucket_name}: {e}")
@@ -80,6 +96,7 @@ class S3SpanExporter(SpanExporterBase):
80
96
  """Synchronous export method that internally handles async logic."""
81
97
  try:
82
98
  # Run the asynchronous export logic in an event loop
99
+ logger.info(f"Exporting {len(spans)} spans to S3.")
83
100
  asyncio.run(self.__export_async(spans))
84
101
  return SpanExportResult.SUCCESS
85
102
  except Exception as e:
@@ -88,6 +105,7 @@ class S3SpanExporter(SpanExporterBase):
88
105
 
89
106
  async def __export_async(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
90
107
  try:
108
+ logger.info(f"__export_async {len(spans)} spans to S3.")
91
109
  # Add spans to the export queue
92
110
  for span in spans:
93
111
  self.export_queue.append(span)
@@ -130,19 +148,22 @@ class S3SpanExporter(SpanExporterBase):
130
148
  batch_to_export = self.export_queue[:self.max_batch_size]
131
149
  serialized_data = self.__serialize_spans(batch_to_export)
132
150
  self.export_queue = self.export_queue[self.max_batch_size:]
133
- try:
134
- if asyncio.get_event_loop().is_running():
135
- task = asyncio.create_task(self._retry_with_backoff(self.__upload_to_s3, serialized_data))
136
- await task
137
- else:
138
- await self._retry_with_backoff(self.__upload_to_s3, serialized_data)
139
-
140
- except Exception as e:
141
- logger.error(f"Failed to upload span batch: {e}")
151
+ # to calculate is_root_span loop over each span in batch_to_export and check if parent id is none or null
152
+ is_root_span = any(not span.parent for span in batch_to_export)
153
+ logger.info(f"Exporting {len(batch_to_export)} spans to S3 is_root_span : {is_root_span}.")
154
+ if self.task_processor is not None and callable(getattr(self.task_processor, 'queue_task', None)):
155
+ self.task_processor.queue_task(self.__upload_to_s3, serialized_data, is_root_span)
156
+ else:
157
+ try:
158
+ self.__upload_to_s3(serialized_data)
159
+ except Exception as e:
160
+ logger.error(f"Failed to upload span batch: {e}")
142
161
 
162
+ @SpanExporterBase.retry_with_backoff(exceptions=(EndpointConnectionError, ConnectionClosedError, ReadTimeoutError, ConnectTimeoutError))
143
163
  def __upload_to_s3(self, span_data_batch: str):
144
164
  current_time = datetime.datetime.now().strftime(self.time_format)
145
- file_name = f"{self.file_prefix}{current_time}.ndjson"
165
+ prefix = self.file_prefix + os.environ.get('MONOCLE_S3_KEY_PREFIX_CURRENT', '')
166
+ file_name = f"{prefix}{current_time}.ndjson"
146
167
  self.s3_client.put_object(
147
168
  Bucket=self.bucket_name,
148
169
  Key=file_name,
@@ -155,4 +176,6 @@ class S3SpanExporter(SpanExporterBase):
155
176
  return True
156
177
 
157
178
  def shutdown(self) -> None:
179
+ if hasattr(self, 'task_processor') and self.task_processor is not None:
180
+ self.task_processor.stop()
158
181
  logger.info("S3SpanExporter has been shut down.")
@@ -0,0 +1,137 @@
1
+ import os
2
+ import time
3
+ import datetime
4
+ import logging
5
+ import asyncio
6
+ from typing import Sequence, Optional
7
+ from opentelemetry.sdk.trace import ReadableSpan
8
+ from opentelemetry.sdk.trace.export import SpanExportResult
9
+ from monocle_apptrace.exporters.base_exporter import SpanExporterBase
10
+ from monocle_apptrace.exporters.exporter_processor import ExportTaskProcessor
11
+ from opendal import Operator
12
+ from opendal.exceptions import PermissionDenied, ConfigInvalid, Unexpected
13
+
14
+ import json
15
+
16
+ logger = logging.getLogger(__name__)
17
+ class OpenDALS3Exporter(SpanExporterBase):
18
+ def __init__(self, bucket_name=None, region_name=None, task_processor: Optional[ExportTaskProcessor] = None):
19
+ super().__init__()
20
+ DEFAULT_FILE_PREFIX = "monocle_trace_"
21
+ DEFAULT_TIME_FORMAT = "%Y-%m-%d__%H.%M.%S"
22
+ self.max_batch_size = 500
23
+ self.export_interval = 1
24
+ self.file_prefix = DEFAULT_FILE_PREFIX
25
+ self.time_format = DEFAULT_TIME_FORMAT
26
+ self.export_queue = []
27
+ self.last_export_time = time.time()
28
+ self.bucket_name = bucket_name or os.getenv("MONOCLE_S3_BUCKET_NAME", "default-bucket")
29
+
30
+ # Initialize OpenDAL S3 operator
31
+ self.op = Operator(
32
+ "s3",
33
+ root = "/",
34
+ region=os.getenv("AWS_REGION", region_name),
35
+ bucket=self.bucket_name,
36
+ access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
37
+ secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
38
+ )
39
+
40
+ self.task_processor = task_processor
41
+ if self.task_processor is not None:
42
+ self.task_processor.start()
43
+
44
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
45
+ """Synchronous export method that internally handles async logic."""
46
+ try:
47
+ # Run the asynchronous export logic in an event loop
48
+ asyncio.run(self.__export_async(spans))
49
+ return SpanExportResult.SUCCESS
50
+ except Exception as e:
51
+ logger.error(f"Error exporting spans: {e}")
52
+ return SpanExportResult.FAILURE
53
+
54
+ async def __export_async(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
55
+ try:
56
+ # Add spans to the export queue
57
+ for span in spans:
58
+ self.export_queue.append(span)
59
+ if len(self.export_queue) >= self.max_batch_size:
60
+ await self.__export_spans()
61
+
62
+ # Check if it's time to force a flush
63
+ current_time = time.time()
64
+ if current_time - self.last_export_time >= self.export_interval:
65
+ await self.__export_spans()
66
+ self.last_export_time = current_time
67
+
68
+ return SpanExportResult.SUCCESS
69
+ except Exception as e:
70
+ logger.error(f"Error exporting spans: {e}")
71
+ return SpanExportResult.FAILURE
72
+
73
+ def __serialize_spans(self, spans: Sequence[ReadableSpan]) -> str:
74
+ try:
75
+ # Serialize spans to JSON or any other format you prefer
76
+ valid_json_list = []
77
+ for span in spans:
78
+ try:
79
+ valid_json_list.append(span.to_json(indent=0).replace("\n", ""))
80
+ except json.JSONDecodeError as e:
81
+ logger.warning(f"Invalid JSON format in span data: {span.context.span_id}. Error: {e}")
82
+ continue
83
+ return "\n".join(valid_json_list) + "\n"
84
+ except Exception as e:
85
+ logger.warning(f"Error serializing spans: {e}")
86
+
87
+ async def __export_spans(self):
88
+ if not self.export_queue:
89
+ return
90
+ # Take a batch of spans from the queue
91
+ batch_to_export = self.export_queue[:self.max_batch_size]
92
+ serialized_data = self.__serialize_spans(batch_to_export)
93
+ self.export_queue = self.export_queue[self.max_batch_size:]
94
+
95
+ # Calculate is_root_span by checking if any span has no parent
96
+ is_root_span = any(not span.parent for span in batch_to_export)
97
+
98
+ if self.task_processor is not None and callable(getattr(self.task_processor, 'queue_task', None)):
99
+ self.task_processor.queue_task(self.__upload_to_s3, serialized_data, is_root_span)
100
+ else:
101
+ try:
102
+ self.__upload_to_s3(serialized_data, is_root_span)
103
+ except Exception as e:
104
+ logger.error(f"Failed to upload span batch: {e}")
105
+
106
+ @SpanExporterBase.retry_with_backoff(exceptions=(Unexpected))
107
+ def __upload_to_s3(self, span_data_batch: str, is_root_span: bool = False):
108
+ current_time = datetime.datetime.now().strftime(self.time_format)
109
+ file_name = f"{self.file_prefix}{current_time}.ndjson"
110
+ try:
111
+ # Attempt to write the span data batch to S3
112
+ self.op.write(file_name, span_data_batch.encode("utf-8"))
113
+ logger.info(f"Span batch uploaded to S3 as {file_name}. Is root span: {is_root_span}")
114
+
115
+ except PermissionDenied as e:
116
+ # S3 bucket is forbidden.
117
+ logger.error(f"Access to bucket {self.bucket_name} is forbidden (403).")
118
+ raise PermissionError(f"Access to bucket {self.bucket_name} is forbidden.")
119
+
120
+ except ConfigInvalid as e:
121
+ # Bucket does not exist.
122
+ if "404" in str(e):
123
+ logger.error("Bucket does not exist. Please check the bucket name and region.")
124
+ raise Exception(f"Bucket does not exist. Error: {e}")
125
+ else:
126
+ logger.error(f"Unexpected error when accessing bucket {self.bucket_name}: {e}")
127
+ raise e
128
+
129
+
130
+ async def force_flush(self, timeout_millis: int = 30000) -> bool:
131
+ await self.__export_spans()
132
+ return True
133
+
134
+ def shutdown(self) -> None:
135
+ if hasattr(self, 'task_processor') and self.task_processor is not None:
136
+ self.task_processor.stop()
137
+ logger.info("S3SpanExporter has been shut down.")
@@ -8,13 +8,15 @@ from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient
8
8
  from azure.core.exceptions import ResourceNotFoundError, ClientAuthenticationError, ServiceRequestError
9
9
  from opentelemetry.sdk.trace import ReadableSpan
10
10
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
11
- from typing import Sequence
11
+ from typing import Sequence, Optional
12
12
  from monocle_apptrace.exporters.base_exporter import SpanExporterBase
13
+ from monocle_apptrace.exporters.exporter_processor import ExportTaskProcessor
13
14
  import json
15
+ from monocle_apptrace.instrumentation.common.constants import MONOCLE_SDK_VERSION
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
  class AzureBlobSpanExporter(SpanExporterBase):
17
- def __init__(self, connection_string=None, container_name=None):
19
+ def __init__(self, connection_string=None, container_name=None, task_processor: Optional[ExportTaskProcessor] = None):
18
20
  super().__init__()
19
21
  DEFAULT_FILE_PREFIX = "monocle_trace_"
20
22
  DEFAULT_TIME_FORMAT = "%Y-%m-%d_%H.%M.%S"
@@ -43,6 +45,10 @@ class AzureBlobSpanExporter(SpanExporterBase):
43
45
  logger.error(f"Error creating container {container_name}: {e}")
44
46
  raise e
45
47
 
48
+ self.task_processor = task_processor
49
+ if self.task_processor is not None:
50
+ self.task_processor.start()
51
+
46
52
  def __container_exists(self, container_name):
47
53
  try:
48
54
  container_client = self.blob_service_client.get_container_client(container_name)
@@ -72,6 +78,12 @@ class AzureBlobSpanExporter(SpanExporterBase):
72
78
  """The actual async export logic is run here."""
73
79
  # Add spans to the export queue
74
80
  for span in spans:
81
+ # Azure blob library has a check to generate it's own span if OpenTelemetry is loaded and Azure trace package is installed (just pip install azure-trace-opentelemetry)
82
+ # With Monocle,OpenTelemetry is always loaded. If the Azure trace package is installed, then it triggers the blob trace generation on every blob operation.
83
+ # Thus, the Monocle span write ends up generating a blob span which again comes back to the exporter .. and would result in an infinite loop.
84
+ # To avoid this, we check if the span has the Monocle SDK version attribute and skip it if it doesn't. That way the blob span genearted by Azure library are not exported.
85
+ if not span.attributes.get(MONOCLE_SDK_VERSION):
86
+ continue # TODO: All exporters to use same base class and check it there
75
87
  self.export_queue.append(span)
76
88
  if len(self.export_queue) >= self.max_batch_size:
77
89
  await self.__export_spans()
@@ -104,25 +116,31 @@ class AzureBlobSpanExporter(SpanExporterBase):
104
116
  batch_to_export = self.export_queue[:self.max_batch_size]
105
117
  serialized_data = self.__serialize_spans(batch_to_export)
106
118
  self.export_queue = self.export_queue[self.max_batch_size:]
107
- try:
108
- if asyncio.get_event_loop().is_running():
109
- task = asyncio.create_task(self._retry_with_backoff(self.__upload_to_blob, serialized_data))
110
- await task
111
- else:
112
- await self._retry_with_backoff(self.__upload_to_blob, serialized_data)
113
- except Exception as e:
114
- logger.error(f"Failed to upload span batch: {e}")
119
+
120
+ # Calculate is_root_span by checking if any span has no parent
121
+ is_root_span = any(not span.parent for span in batch_to_export)
122
+
123
+ if self.task_processor is not None and callable(getattr(self.task_processor, 'queue_task', None)):
124
+ self.task_processor.queue_task(self.__upload_to_blob, serialized_data, is_root_span)
125
+ else:
126
+ try:
127
+ self.__upload_to_blob(serialized_data, is_root_span)
128
+ except Exception as e:
129
+ logger.error(f"Failed to upload span batch: {e}")
115
130
 
116
- def __upload_to_blob(self, span_data_batch: str):
131
+ @SpanExporterBase.retry_with_backoff(exceptions=(ResourceNotFoundError, ClientAuthenticationError, ServiceRequestError))
132
+ def __upload_to_blob(self, span_data_batch: str, is_root_span: bool = False):
117
133
  current_time = datetime.datetime.now().strftime(self.time_format)
118
134
  file_name = f"{self.file_prefix}{current_time}.ndjson"
119
135
  blob_client = self.blob_service_client.get_blob_client(container=self.container_name, blob=file_name)
120
136
  blob_client.upload_blob(span_data_batch, overwrite=True)
121
- logger.info(f"Span batch uploaded to Azure Blob Storage as {file_name}.")
137
+ logger.info(f"Span batch uploaded to Azure Blob Storage as {file_name}. Is root span: {is_root_span}")
122
138
 
123
139
  async def force_flush(self, timeout_millis: int = 30000) -> bool:
124
140
  await self.__export_spans()
125
141
  return True
126
142
 
127
143
  def shutdown(self) -> None:
144
+ if hasattr(self, 'task_processor') and self.task_processor is not None:
145
+ self.task_processor.stop()
128
146
  logger.info("AzureBlobSpanExporter has been shut down.")
@@ -0,0 +1,162 @@
1
+ import os
2
+ import time
3
+ import datetime
4
+ import logging
5
+ import asyncio
6
+ from opentelemetry.sdk.trace import ReadableSpan
7
+ from opentelemetry.sdk.trace.export import SpanExportResult
8
+ from typing import Sequence, Optional
9
+ from opendal import Operator
10
+ from monocle_apptrace.exporters.base_exporter import SpanExporterBase
11
+ from monocle_apptrace.exporters.exporter_processor import ExportTaskProcessor
12
+ from opendal.exceptions import Unexpected, PermissionDenied, NotFound
13
+ import json
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ class OpenDALAzureExporter(SpanExporterBase):
18
+ def __init__(self, connection_string=None, container_name=None, task_processor: Optional[ExportTaskProcessor] = None):
19
+ super().__init__()
20
+ DEFAULT_FILE_PREFIX = "monocle_trace_"
21
+ DEFAULT_TIME_FORMAT = "%Y-%m-%d_%H.%M.%S"
22
+ self.max_batch_size = 500
23
+ self.export_interval = 1
24
+ self.container_name = container_name
25
+
26
+ # Default values
27
+ self.file_prefix = DEFAULT_FILE_PREFIX
28
+ self.time_format = DEFAULT_TIME_FORMAT
29
+ self.export_queue = [] # Add this line to initialize export_queue
30
+ self.last_export_time = time.time() # Add this line to initialize last_export_time
31
+
32
+ # Validate input
33
+ if not connection_string:
34
+ connection_string = os.getenv('MONOCLE_BLOB_CONNECTION_STRING')
35
+ if not connection_string:
36
+ raise ValueError("Azure Storage connection string is not provided or set in environment variables.")
37
+
38
+ if not container_name:
39
+ container_name = os.getenv('MONOCLE_BLOB_CONTAINER_NAME', 'default-container')
40
+ endpoint, account_name , account_key = self.parse_connection_string(connection_string)
41
+
42
+ if not account_name or not account_key:
43
+ raise ValueError("AccountName or AccountKey missing in the connection string.")
44
+
45
+ try:
46
+ # Initialize OpenDAL operator with explicit credentials
47
+ self.operator = Operator(
48
+ "azblob",
49
+ endpoint=endpoint,
50
+ account_name=account_name,
51
+ account_key=account_key,
52
+ container=container_name
53
+ )
54
+ except Exception as e:
55
+ raise RuntimeError(f"Failed to initialize OpenDAL operator: {e}")
56
+
57
+ self.task_processor = task_processor
58
+ if self.task_processor is not None:
59
+ self.task_processor.start()
60
+
61
+ def parse_connection_string(self,connection_string):
62
+ connection_params = dict(item.split('=', 1) for item in connection_string.split(';') if '=' in item)
63
+
64
+ account_name = connection_params.get('AccountName')
65
+ account_key = connection_params.get('AccountKey')
66
+ endpoint_suffix = connection_params.get('EndpointSuffix')
67
+
68
+ if not all([account_name, account_key, endpoint_suffix]):
69
+ raise ValueError("Invalid connection string. Ensure it contains AccountName, AccountKey, and EndpointSuffix.")
70
+
71
+ endpoint = f"https://{account_name}.blob.{endpoint_suffix}"
72
+ return endpoint, account_name, account_key
73
+
74
+
75
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
76
+ """Synchronous export method that internally handles async logic."""
77
+ try:
78
+ # Run the asynchronous export logic in an event loop
79
+ asyncio.run(self._export_async(spans))
80
+ return SpanExportResult.SUCCESS
81
+ except Exception as e:
82
+ logger.error(f"Error exporting spans: {e}")
83
+ return SpanExportResult.FAILURE
84
+
85
+ async def _export_async(self, spans: Sequence[ReadableSpan]):
86
+ """The actual async export logic is run here."""
87
+ # Add spans to the export queue
88
+ for span in spans:
89
+ self.export_queue.append(span)
90
+ if len(self.export_queue) >= self.max_batch_size:
91
+ await self.__export_spans()
92
+
93
+ # Force a flush if the interval has passed
94
+ current_time = time.time()
95
+ if current_time - self.last_export_time >= self.export_interval:
96
+ await self.__export_spans()
97
+ self.last_export_time = current_time
98
+
99
+ def __serialize_spans(self, spans: Sequence[ReadableSpan]) -> str:
100
+ try:
101
+ valid_json_list = []
102
+ for span in spans:
103
+ try:
104
+ valid_json_list.append(span.to_json(indent=0).replace("\n", ""))
105
+ except json.JSONDecodeError as e:
106
+ logger.warning(f"Invalid JSON format in span data: {span.context.span_id}. Error: {e}")
107
+ continue
108
+
109
+ ndjson_data = "\n".join(valid_json_list) + "\n"
110
+ return ndjson_data
111
+ except Exception as e:
112
+ logger.warning(f"Error serializing spans: {e}")
113
+
114
+ async def __export_spans(self):
115
+ if len(self.export_queue) == 0:
116
+ return
117
+
118
+ batch_to_export = self.export_queue[:self.max_batch_size]
119
+ serialized_data = self.__serialize_spans(batch_to_export)
120
+ self.export_queue = self.export_queue[self.max_batch_size:]
121
+
122
+ # Calculate is_root_span by checking if any span has no parent
123
+ is_root_span = any(not span.parent for span in batch_to_export)
124
+
125
+ if self.task_processor is not None and callable(getattr(self.task_processor, 'queue_task', None)):
126
+ self.task_processor.queue_task(self.__upload_to_opendal, serialized_data, is_root_span)
127
+ else:
128
+ try:
129
+ self.__upload_to_opendal(serialized_data, is_root_span)
130
+ except Exception as e:
131
+ logger.error(f"Failed to upload span batch: {e}")
132
+
133
+ @SpanExporterBase.retry_with_backoff(exceptions=(Unexpected,))
134
+ def __upload_to_opendal(self, span_data_batch: str, is_root_span: bool = False):
135
+ current_time = datetime.datetime.now().strftime(self.time_format)
136
+ file_name = f"{self.file_prefix}{current_time}.ndjson"
137
+
138
+ try:
139
+ self.operator.write(file_name, span_data_batch.encode('utf-8'))
140
+ logger.info(f"Span batch uploaded to Azure Blob Storage as {file_name}. Is root span: {is_root_span}")
141
+ except PermissionDenied as e:
142
+ # Azure Container is forbidden.
143
+ logger.error(f"Access to container {self.container_name} is forbidden (403).")
144
+ raise PermissionError(f"Access to container {self.container_name} is forbidden.")
145
+
146
+ except NotFound as e:
147
+ # Container does not exist.
148
+ if "404" in str(e):
149
+ logger.error("Container does not exist. Please check the container name.")
150
+ raise Exception(f"Container does not exist. Error: {e}")
151
+ else:
152
+ logger.error(f"Unexpected NotFound error when accessing container {self.container_name}: {e}")
153
+ raise e
154
+
155
+ async def force_flush(self, timeout_millis: int = 30000) -> bool:
156
+ await self.__export_spans()
157
+ return True
158
+
159
+ def shutdown(self) -> None:
160
+ if hasattr(self, 'task_processor') and self.task_processor is not None:
161
+ self.task_processor.stop()
162
+ logger.info("OpenDALAzureExporter has been shut down.")
@@ -2,7 +2,6 @@ import time
2
2
  import random
3
3
  import logging
4
4
  from abc import ABC, abstractmethod
5
- from azure.core.exceptions import ServiceRequestError, ClientAuthenticationError
6
5
  from opentelemetry.sdk.trace import ReadableSpan
7
6
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
8
7
  from typing import Sequence
@@ -28,20 +27,22 @@ class SpanExporterBase(ABC):
28
27
  def shutdown(self) -> None:
29
28
  pass
30
29
 
31
- async def _retry_with_backoff(self, func, *args, **kwargs):
32
- """Handle retries with exponential backoff."""
33
- attempt = 0
34
- while attempt < self.max_retries:
35
- try:
36
- return func(*args, **kwargs)
37
- except ServiceRequestError as e:
38
- logger.warning(f"Network connectivity error: {e}. Retrying in {self.backoff_factor ** attempt} seconds...")
39
- sleep_time = self.backoff_factor * (2 ** attempt) + random.uniform(0, 1)
40
- await asyncio.sleep(sleep_time)
41
- attempt += 1
42
- except ClientAuthenticationError as e:
43
- logger.error(f"Failed to authenticate: {str(e)}")
44
- break
45
-
46
- logger.error("Max retries exceeded.")
47
- raise ServiceRequestError(message="Max retries exceeded.")
30
+ @staticmethod
31
+ def retry_with_backoff(retries=3, backoff_in_seconds=1, max_backoff_in_seconds=32, exceptions=(Exception,)):
32
+ def decorator(func):
33
+ def wrapper(*args, **kwargs):
34
+ attempt = 0
35
+ while attempt < retries:
36
+ try:
37
+ return func(*args, **kwargs)
38
+ except exceptions as e:
39
+ attempt += 1
40
+ sleep_time = min(max_backoff_in_seconds, backoff_in_seconds * (2 ** (attempt - 1)))
41
+ sleep_time = sleep_time * (1 + random.uniform(-0.1, 0.1)) # Add jitter
42
+ logger.warning(f"Network connectivity error, Attempt {attempt} failed: {e}. Retrying in {sleep_time:.2f} seconds...")
43
+ time.sleep(sleep_time)
44
+ raise Exception(f"Failed after {retries} attempts")
45
+
46
+ return wrapper
47
+
48
+ return decorator