|
1 | 1 | # Copyright (c) Microsoft Corporation. All rights reserved. |
2 | 2 | # Licensed under the MIT License. |
3 | 3 | import getpass |
| 4 | +import json |
4 | 5 | import logging |
5 | 6 | import os |
6 | 7 | import tempfile |
|
27 | 28 | from azure.monitor.opentelemetry.exporter._generated.exporter.models import ( |
28 | 29 | TelemetryItem, |
29 | 30 | ) |
| 31 | +from azure.monitor.opentelemetry.exporter._generated.exporter._utils.model_base import ( |
| 32 | + SdkJSONEncoder, |
| 33 | +) |
30 | 34 | from azure.monitor.opentelemetry.exporter._constants import ( |
31 | 35 | _ALLOWED_REDIRECT_DOMAIN_SUFFIXES, |
32 | 36 | _AZURE_MONITOR_DISTRO_VERSION_ARG, |
|
95 | 99 | _TEMPDIR_PREFIX = "opentelemetry-python-" |
96 | 100 | _SERVICE_API_LATEST = "2020-09-15_Preview" |
97 | 101 |
|
| 102 | +# Maximum request body size (in bytes) the ingestion endpoint accepts on the wire before |
| 103 | +# responding with 413 (Payload Too Large). |
| 104 | +_MAX_INGESTION_PAYLOAD_SIZE_BYTES = 30 * 1000 * 1000 |
| 105 | + |
| 106 | +# Maximum size (in bytes) of a single telemetry item the ingestion endpoint accepts. Items |
| 107 | +# larger than this are rejected per-item (surfaced as a 400 within a 206 partial response) and |
| 108 | +# cannot be split further. ~1.2 MiB. |
| 109 | +_MAX_INGESTION_ITEM_SIZE_BYTES = 1258291 |
| 110 | + |
98 | 111 |
|
99 | 112 | class ExportResult(Enum): |
100 | 113 | SUCCESS = 0 |
@@ -317,6 +330,41 @@ def _handle_transmit_from_storage(self, envelopes: List[TelemetryItem], result: |
317 | 330 | if self._should_collect_customer_sdkstats(): |
318 | 331 | track_dropped_items(envelopes, DropCode.CLIENT_STORAGE_DISABLED) |
319 | 332 |
|
| 333 | + def _handle_payload_too_large( |
| 334 | + self, envelopes: List[TelemetryItem], response_error: HttpResponseError |
| 335 | + ) -> ExportResult: |
| 336 | + """Handle a 413 (Payload Too Large) response by splitting and persisting for retry.""" |
| 337 | + if len(envelopes) <= 1 or not self.storage: |
| 338 | + if not self._is_stats_exporter(): |
| 339 | + logger.warning( |
| 340 | + "Payload too large and cannot be split further; dropping %d envelope(s): %s.", |
| 341 | + len(envelopes), |
| 342 | + response_error.message, |
| 343 | + ) |
| 344 | + if self._should_collect_customer_sdkstats() and isinstance(response_error.status_code, int): |
| 345 | + track_dropped_items(envelopes, response_error.status_code) |
| 346 | + return ExportResult.FAILED_NOT_RETRYABLE |
| 347 | + |
| 348 | + for sub_batch in _split_oversized_batch(envelopes): |
| 349 | + try: |
| 350 | + self.storage.put([x.as_dict() for x in sub_batch]) |
| 351 | + except Exception: # pylint: disable=broad-except |
| 352 | + # Persisting a split sub-batch failed (e.g. disk full or a serialization |
| 353 | + # error). Drop just this sub-batch rather than aborting the whole split so the |
| 354 | + # remaining sub-batches still get a chance to be persisted and retried. |
| 355 | + if not self._is_stats_exporter(): |
| 356 | + logger.warning( # pylint: disable=do-not-log-exceptions-if-not-debug |
| 357 | + "Failed to persist a split sub-batch of %d envelope(s) after a 413; " |
| 358 | + "these envelopes are dropped.", |
| 359 | + len(sub_batch), |
| 360 | + ) |
| 361 | + if self._should_collect_customer_sdkstats() and isinstance(response_error.status_code, int): |
| 362 | + track_dropped_items(sub_batch, response_error.status_code) |
| 363 | + continue |
| 364 | + if self._should_collect_customer_sdkstats(): |
| 365 | + track_retry_items(sub_batch, response_error) |
| 366 | + return ExportResult.FAILED_NOT_RETRYABLE |
| 367 | + |
320 | 368 | # pylint: disable=too-many-branches |
321 | 369 | # pylint: disable=too-many-nested-blocks |
322 | 370 | # pylint: disable=too-many-statements |
@@ -446,12 +494,31 @@ def _transmit(self, envelopes: List[TelemetryItem], _skip_rate_limit: bool = Fal |
446 | 494 | and isinstance(error.status_code, int) |
447 | 495 | ): |
448 | 496 | track_dropped_items([envelopes[error.index]], error.status_code) |
449 | | - logger.error( |
450 | | - "Data drop %s: %s %s.", |
451 | | - error.status_code, |
452 | | - error.message, |
453 | | - (envelopes[error.index] if error.index is not None else ""), |
454 | | - ) |
| 497 | + dropped_envelope = envelopes[error.index] if error.index is not None else None |
| 498 | + # An envelope that exceeds the per-item ingestion size limit (~1.2 MiB) |
| 499 | + # cannot be split further and is dropped here. Dumping it would flood the |
| 500 | + # log with a huge (and potentially PII-laden) payload, so log a concise |
| 501 | + # summary instead. For all other drops keep the existing behavior of |
| 502 | + # logging the full envelope to aid debugging. |
| 503 | + if ( |
| 504 | + dropped_envelope is not None |
| 505 | + and _get_envelope_serialized_size(dropped_envelope) |
| 506 | + > _MAX_INGESTION_ITEM_SIZE_BYTES |
| 507 | + ): |
| 508 | + logger.error( |
| 509 | + "Data drop %s: %s. Envelope of type %s exceeds the per-item " |
| 510 | + "ingestion size limit and is omitted from the log.", |
| 511 | + error.status_code, |
| 512 | + error.message, |
| 513 | + dropped_envelope.name, |
| 514 | + ) |
| 515 | + else: |
| 516 | + logger.error( |
| 517 | + "Data drop %s: %s. %s", |
| 518 | + error.status_code, |
| 519 | + error.message, |
| 520 | + (dropped_envelope if dropped_envelope is not None else ""), |
| 521 | + ) |
455 | 522 | storage = self.storage |
456 | 523 | if storage and resend_envelopes: |
457 | 524 | envelopes_to_store = [x.as_dict() for x in resend_envelopes] |
@@ -580,27 +647,8 @@ def _transmit(self, envelopes: List[TelemetryItem], _skip_rate_limit: bool = Fal |
580 | 647 | elif response_error.status_code == 413: |
581 | 648 | if self._should_collect_stats(): |
582 | 649 | _update_requests_map(_REQ_FAILURE_NAME[1], value=response_error.status_code) |
583 | | - if self.storage and len(envelopes) > 1: |
584 | | - midpoint = len(envelopes) // 2 |
585 | | - for half in (envelopes[:midpoint], envelopes[midpoint:]): |
586 | | - envelopes_to_store = [x.as_dict() for x in half] |
587 | | - self.storage.put(envelopes_to_store) |
588 | | - if self._should_collect_customer_sdkstats(): |
589 | | - # These halves are persisted for retry at a smaller size. |
590 | | - track_retry_items(half, response_error) |
591 | | - else: |
592 | | - # Single envelope (or storage disabled): cannot split, so drop it. |
593 | | - if not self._is_stats_exporter(): |
594 | | - logger.error( |
595 | | - "Payload too large and cannot be split further: %s.", |
596 | | - response_error.message, |
597 | | - ) |
598 | | - if self._should_collect_customer_sdkstats() and isinstance( |
599 | | - response_error.status_code, int |
600 | | - ): |
601 | | - track_dropped_items(envelopes, response_error.status_code) |
602 | | - # Mark as not retryable because we already write to storage here |
603 | | - result = ExportResult.FAILED_NOT_RETRYABLE |
| 650 | + result = self._handle_payload_too_large(envelopes, response_error) |
| 651 | + |
604 | 652 | else: |
605 | 653 | # Any other status code counts as failure (non-retryable) |
606 | 654 | # 400 - Invalid - The server cannot or will not process the request due to the invalid telemetry (invalid data, iKey, etc.) |
@@ -830,6 +878,43 @@ def _host(netloc: str) -> str: |
830 | 878 | return False |
831 | 879 |
|
832 | 880 |
|
| 881 | +def _get_envelope_serialized_size(envelope: TelemetryItem) -> int: |
| 882 | + """Return the serialized wire size (in bytes) of a single envelope on the wire.""" |
| 883 | + try: |
| 884 | + serialized = json.dumps(envelope, cls=SdkJSONEncoder, exclude_readonly=True) |
| 885 | + return len(serialized.encode("utf-8")) |
| 886 | + except Exception: # pylint: disable=broad-except |
| 887 | + return _MAX_INGESTION_PAYLOAD_SIZE_BYTES |
| 888 | + |
| 889 | + |
| 890 | +def _split_oversized_batch(envelopes: List[TelemetryItem]) -> List[List[TelemetryItem]]: |
| 891 | + """Split an oversized batch into sub-batches that each fit under the ingestion size limit.""" |
| 892 | + chunks: List[List[TelemetryItem]] = [] |
| 893 | + current: List[TelemetryItem] = [] |
| 894 | + current_size = 2 # account for the enclosing "[" and "]" of the JSON array |
| 895 | + for envelope in envelopes: |
| 896 | + envelope_size = _get_envelope_serialized_size(envelope) |
| 897 | + # Every element after the first is preceded by a "," separator inside the JSON array. |
| 898 | + separator_size = 1 if current else 0 |
| 899 | + if current and current_size + separator_size + envelope_size > _MAX_INGESTION_PAYLOAD_SIZE_BYTES: |
| 900 | + chunks.append(current) |
| 901 | + current = [] |
| 902 | + current_size = 2 |
| 903 | + separator_size = 0 |
| 904 | + current.append(envelope) |
| 905 | + current_size += separator_size + envelope_size |
| 906 | + if current: |
| 907 | + chunks.append(current) |
| 908 | + # Guarantee progress: if size-based packing produced a single chunk (e.g. because the byte |
| 909 | + # estimate under-reported), fall back to halving by count so repeated 413s keep shrinking the |
| 910 | + # batch. Only split when there is more than one envelope; a single envelope cannot be divided |
| 911 | + # and is handled by the caller (which drops it), so this never produces an empty sub-batch. |
| 912 | + if len(chunks) < 2 and len(envelopes) > 1: |
| 913 | + midpoint = len(envelopes) // 2 |
| 914 | + chunks = [envelopes[:midpoint], envelopes[midpoint:]] |
| 915 | + return chunks |
| 916 | + |
| 917 | + |
833 | 918 | def _is_invalid_code(response_code: Optional[int]) -> bool: |
834 | 919 | """Determine if response is a invalid response. |
835 | 920 |
|
|
0 commit comments