Skip to content

Commit a1ebb6a

Browse files
committed
Add support for 413 - payload too large handling
1 parent e58af2b commit a1ebb6a

2 files changed

Lines changed: 292 additions & 27 deletions

File tree

‎sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py‎

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33
import getpass
4+
import json
45
import logging
56
import os
67
import tempfile
@@ -27,6 +28,9 @@
2728
from azure.monitor.opentelemetry.exporter._generated.exporter.models import (
2829
TelemetryItem,
2930
)
31+
from azure.monitor.opentelemetry.exporter._generated.exporter._utils.model_base import (
32+
SdkJSONEncoder,
33+
)
3034
from azure.monitor.opentelemetry.exporter._constants import (
3135
_ALLOWED_REDIRECT_DOMAIN_SUFFIXES,
3236
_AZURE_MONITOR_DISTRO_VERSION_ARG,
@@ -95,6 +99,15 @@
9599
_TEMPDIR_PREFIX = "opentelemetry-python-"
96100
_SERVICE_API_LATEST = "2020-09-15_Preview"
97101

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+
98111

99112
class ExportResult(Enum):
100113
SUCCESS = 0
@@ -317,6 +330,41 @@ def _handle_transmit_from_storage(self, envelopes: List[TelemetryItem], result:
317330
if self._should_collect_customer_sdkstats():
318331
track_dropped_items(envelopes, DropCode.CLIENT_STORAGE_DISABLED)
319332

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+
320368
# pylint: disable=too-many-branches
321369
# pylint: disable=too-many-nested-blocks
322370
# pylint: disable=too-many-statements
@@ -446,12 +494,31 @@ def _transmit(self, envelopes: List[TelemetryItem], _skip_rate_limit: bool = Fal
446494
and isinstance(error.status_code, int)
447495
):
448496
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+
)
455522
storage = self.storage
456523
if storage and resend_envelopes:
457524
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
580647
elif response_error.status_code == 413:
581648
if self._should_collect_stats():
582649
_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+
604652
else:
605653
# Any other status code counts as failure (non-retryable)
606654
# 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:
830878
return False
831879

832880

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+
833918
def _is_invalid_code(response_code: Optional[int]) -> bool:
834919
"""Determine if response is a invalid response.
835920

0 commit comments

Comments
 (0)