From ee78b0f2f63f3172fcbf43bc4a406b33e9ddc983 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Thu, 9 Apr 2026 10:21:33 -0700 Subject: [PATCH 1/9] Standardize Python CompactConsoleLogRecordExporter JSON output to match canonical schema --- .../logs/compact_console_log_exporter.py | 160 +++++++++- .../logs/test_compact_console_log_exporter.py | 278 +++++++++++++++--- 2 files changed, 383 insertions(+), 55 deletions(-) diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py index 83f58dfa0..f2f7c61b5 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py @@ -1,16 +1,160 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import json +import logging import re -from typing import Sequence +import sys +from datetime import datetime, timezone +from typing import IO, Optional, Sequence -from opentelemetry.sdk._logs import ReadableLogRecord -from opentelemetry.sdk._logs.export import ConsoleLogRecordExporter, LogRecordExportResult +from opentelemetry.sdk._logs.export import LogExportResult +# Support both old (LogData/LogExporter) and new (ReadableLogRecord/LogRecordExporter) APIs +try: + from opentelemetry.sdk._logs.export import LogRecordExporter + + _BASE_CLASS = LogRecordExporter +except ImportError: + from opentelemetry.sdk._logs.export import LogExporter + + _BASE_CLASS = LogExporter + +_logger = logging.getLogger(__name__) + + +class CompactConsoleLogRecordExporter(_BASE_CLASS): + """Exports log records as compact JSON to stdout. + + Produces a single-line JSON object per log record matching the canonical + schema shared across all ADOT language implementations. This exporter is + used in AWS Lambda environments when OTEL_LOGS_EXPORTER=console. + + If the standardized serialization fails for any reason, falls back to + the upstream SDK's to_json() format to avoid breaking existing infrastructure. + """ + + def __init__(self, out: IO = None): + self._out = out or sys.stdout + self._shutdown = False + + def export(self, batch: Sequence) -> LogExportResult: + if self._shutdown: + return LogExportResult.FAILURE -class CompactConsoleLogRecordExporter(ConsoleLogRecordExporter): - def export(self, batch: Sequence[ReadableLogRecord]): for data in batch: - formatted_json = self.formatter(data.log_record) - print(re.sub(r"\s*([{}[\]:,])\s*", r"\1", formatted_json), flush=True) + try: + line = self._to_compact_json(data) + except Exception: + _logger.debug( + "Failed to serialize log record with standardized format, falling back to upstream SDK", + exc_info=True, + ) + line = self._fallback_format(data) + + self._out.write(line + "\n") + self._out.flush() + + return LogExportResult.SUCCESS + + def shutdown(self): + self._shutdown = True + + def _to_compact_json(self, data) -> str: + # Support both ReadableLogRecord (1.40+) and LogData (older) APIs. + # ReadableLogRecord: .log_record, .resource, .instrumentation_scope + # LogData: .log_record, .instrumentation_scope (resource on log_record) + record = data.log_record + resource = getattr(data, "resource", None) or getattr(record, "resource", None) + scope = getattr(data, "instrumentation_scope", None) + + # Resource + resource_attrs = {} + if resource and resource.attributes: + for k, v in resource.attributes.items(): + resource_attrs[k] = str(v) + resource_schema_url = "" + if resource and hasattr(resource, "schema_url") and resource.schema_url: + resource_schema_url = resource.schema_url + + # Span context validity: both trace_id and span_id must be non-zero + trace_id = getattr(record, "trace_id", None) + span_id = getattr(record, "span_id", None) + trace_id_valid = trace_id is not None and span_id is not None and trace_id != 0 and span_id != 0 + + # Attributes — coerce all values to strings + attrs = {} + if record.attributes: + for k, v in record.attributes.items(): + attrs[k] = str(v) + + # Severity text from severity number enum name (matches OTel spec names) + severity_text = record.severity_number.name if record.severity_number is not None else "UNSPECIFIED" + severity_number = record.severity_number.value if record.severity_number is not None else 0 + + # Instrumentation scope + scope_name = "" + scope_version = "" + scope_schema_url = "" + if scope: + scope_name = getattr(scope, "name", "") or "" + scope_version = getattr(scope, "version", "") or "" + scope_schema_url = getattr(scope, "schema_url", "") or "" + + # Dropped attributes + dropped = 0 + if hasattr(data, "dropped_attributes"): + dropped = data.dropped_attributes + elif hasattr(record, "dropped_attributes"): + dropped = record.dropped_attributes + + output = { + "resource": { + "attributes": resource_attrs, + "schemaUrl": resource_schema_url, + }, + "body": record.body if record.body is not None else None, + "severityNumber": severity_number, + "severityText": severity_text, + "attributes": attrs, + "droppedAttributes": dropped, + "timestamp": _format_nanos(record.timestamp), + "observedTimestamp": _format_nanos(record.observed_timestamp), + "traceId": format(trace_id, "032x") if trace_id_valid else "", + "spanId": format(span_id, "016x") if trace_id_valid else "", + "traceFlags": int(record.trace_flags) if record.trace_flags is not None else 0, + "instrumentationScope": { + "name": scope_name, + "version": scope_version, + "schemaUrl": scope_schema_url, + }, + } + + return json.dumps(output, separators=(",", ":")) + + @staticmethod + def _fallback_format(data) -> str: + """Fall back to upstream SDK's to_json() with whitespace stripped.""" + # ReadableLogRecord has to_json() directly; LogData has it on .log_record + obj = data if hasattr(data, "to_json") else data.log_record + formatted_json = obj.to_json() + return re.sub(r"\s*([{}[\]:,])\s*", r"\1", formatted_json) + + +def _format_nanos(nanos) -> Optional[str]: + """Convert epoch nanoseconds to ISO-8601 UTC string with trailing zero truncation. - return LogRecordExportResult.SUCCESS + Matches Java's DateTimeFormatter.ISO_INSTANT behavior: + - 2001-09-09T01:46:40Z (no fractional seconds when millis == 0) + - 2001-09-09T01:46:40.1Z (truncated trailing zeros) + - 2001-09-09T01:46:40.12Z + - 2001-09-09T01:46:40.123Z + """ + if nanos is None or nanos == 0: + return None + millis = nanos // 1_000_000 + dt = datetime.fromtimestamp(millis / 1000, tz=timezone.utc) + frac_millis = millis % 1000 + if frac_millis == 0: + return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z" + frac = f".{frac_millis:03d}".rstrip("0") + return dt.strftime("%Y-%m-%dT%H:%M:%S") + frac + "Z" diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py index 7d4187613..7d20f4e76 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py @@ -1,72 +1,256 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import io +import json import unittest -from unittest.mock import Mock, patch from amazon.opentelemetry.distro.exporter.console.logs.compact_console_log_exporter import ( CompactConsoleLogRecordExporter, ) -from opentelemetry.sdk._logs.export import LogRecordExportResult +from opentelemetry._logs import SeverityNumber +from opentelemetry.sdk._logs.export import LogExportResult +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.trace import TraceFlags +# Support both old (LogData) and new (ReadableLogRecord) SDK APIs +try: + from opentelemetry.sdk._logs import ReadableLogRecord as _ReadableLogRecord -class TestCompactConsoleLogRecordExporter(unittest.TestCase): + _HAS_READABLE_LOG_RECORD = True +except ImportError: + _HAS_READABLE_LOG_RECORD = False - def setUp(self): - self.exporter = CompactConsoleLogRecordExporter() +from opentelemetry.sdk._logs import LogRecord - @patch("builtins.print") - def test_export_compresses_json(self, mock_print): - # Mock log data - mock_log_data = Mock() - mock_log_record = Mock() - mock_log_data.log_record = mock_log_record +try: + from opentelemetry.sdk._logs import LogData +except ImportError: + LogData = None - # Mock formatted JSON with whitespace - formatted_json = '{\n "body": "test message",\n "severity_number": 9,\n "attributes": {\n "key": "value"\n }\n}' # noqa: E501 - self.exporter.formatter = Mock(return_value=formatted_json) - # Call export - result = self.exporter.export([mock_log_data]) +def _make_log_data( + body="Test log message", + severity_number=SeverityNumber.INFO, + trace_id=int("12345678901234567890123456789012", 16), + span_id=int("1234567890123456", 16), + trace_flags=TraceFlags(TraceFlags.SAMPLED), + timestamp=1000000000 * 1_000_000_000, + observed_timestamp=1000000000 * 1_000_000_000, + attributes=None, + resource=None, + scope=None, +): + if attributes is None: + attributes = {"key": "value"} + if resource is None: + resource = Resource( + attributes={"service.name": "test-service"}, + schema_url="https://opentelemetry.io/schemas/1.0.0", + ) + if scope is None: + scope = InstrumentationScope( + name="test-scope", + version="1.0.0", + schema_url="https://opentelemetry.io/schemas/1.0.0", + ) - # Verify result - self.assertEqual(result, LogRecordExportResult.SUCCESS) + log_record = LogRecord( + timestamp=timestamp, + observed_timestamp=observed_timestamp, + trace_id=trace_id, + span_id=span_id, + trace_flags=trace_flags, + severity_number=severity_number, + body=body, + resource=resource, + attributes=attributes if attributes else None, + ) - # Verify print calls - self.assertEqual(mock_print.call_count, 1) - mock_print.assert_called_with( - '{"body":"test message","severity_number":9,"attributes":{"key":"value"}}', flush=True + # Use ReadableLogRecord (1.40+) if available, otherwise LogData + if _HAS_READABLE_LOG_RECORD: + return _ReadableLogRecord( + log_record=log_record, + resource=resource, + instrumentation_scope=scope, ) + return LogData(log_record=log_record, instrumentation_scope=scope) + + +class TestCompactConsoleLogRecordExporter(unittest.TestCase): + def setUp(self): + self.buf = io.StringIO() + self.exporter = CompactConsoleLogRecordExporter(out=self.buf) + + def _get_output(self): + return self.buf.getvalue().strip() + + def _get_parsed(self): + return json.loads(self._get_output()) + + def test_export_with_all_fields_set(self): + data = _make_log_data() + result = self.exporter.export([data]) + + self.assertEqual(result, LogExportResult.SUCCESS) + parsed = self._get_parsed() + + # Validate all top-level fields are present + expected_keys = [ + "resource", "body", "severityNumber", "severityText", + "attributes", "droppedAttributes", "timestamp", "observedTimestamp", + "traceId", "spanId", "traceFlags", "instrumentationScope", + ] + for key in expected_keys: + self.assertIn(key, parsed, f"Missing key: {key}") - @patch("builtins.print") - def test_export_multiple_records(self, mock_print): - # Mock multiple log data - mock_log_data1 = Mock() - mock_log_data2 = Mock() - mock_log_data1.log_record = Mock() - mock_log_data2.log_record = Mock() + # Validate values + self.assertEqual(parsed["body"], "Test log message") + self.assertEqual(parsed["severityNumber"], 9) + self.assertEqual(parsed["severityText"], "INFO") + self.assertEqual(parsed["attributes"], {"key": "value"}) + self.assertEqual(parsed["droppedAttributes"], 0) + self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40Z") + self.assertEqual(parsed["observedTimestamp"], "2001-09-09T01:46:40Z") + self.assertEqual(parsed["traceId"], "12345678901234567890123456789012") + self.assertEqual(parsed["spanId"], "1234567890123456") + self.assertEqual(parsed["traceFlags"], 1) - formatted_json = '{\n "body": "test"\n}' - self.exporter.formatter = Mock(return_value=formatted_json) + # Validate nested objects + self.assertIn("attributes", parsed["resource"]) + self.assertEqual(parsed["resource"]["attributes"]["service.name"], "test-service") + self.assertEqual(parsed["resource"]["schemaUrl"], "https://opentelemetry.io/schemas/1.0.0") - # Call export - result = self.exporter.export([mock_log_data1, mock_log_data2]) + scope = parsed["instrumentationScope"] + self.assertEqual(scope["name"], "test-scope") + self.assertEqual(scope["version"], "1.0.0") + self.assertEqual(scope["schemaUrl"], "https://opentelemetry.io/schemas/1.0.0") - # Verify result - self.assertEqual(result, LogRecordExportResult.SUCCESS) + def test_null_body(self): + data = _make_log_data(body=None) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertIsNone(parsed["body"]) - # Verify print calls - self.assertEqual(mock_print.call_count, 2) # 2 records - # Each record should print compact JSON - expected_calls = [unittest.mock.call('{"body":"test"}', flush=True)] * 2 - mock_print.assert_has_calls(expected_calls) + def test_zero_timestamps(self): + data = _make_log_data(timestamp=0, observed_timestamp=0) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertIsNone(parsed["timestamp"]) + self.assertIsNone(parsed["observedTimestamp"]) - @patch("builtins.print") - def test_export_empty_batch(self, mock_print): - # Call export with empty batch + def test_invalid_span_context_all_zeros(self): + data = _make_log_data(trace_id=0, span_id=0) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["traceId"], "") + self.assertEqual(parsed["spanId"], "") + + def test_invalid_trace_id_only(self): + data = _make_log_data(trace_id=0, span_id=int("1234567890123456", 16)) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["traceId"], "") + self.assertEqual(parsed["spanId"], "") + + def test_invalid_span_id_only(self): + data = _make_log_data(trace_id=int("12345678901234567890123456789012", 16), span_id=0) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["traceId"], "") + self.assertEqual(parsed["spanId"], "") + + def test_no_span_context(self): + data = _make_log_data(trace_id=None, span_id=None, trace_flags=None) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["traceId"], "") + self.assertEqual(parsed["spanId"], "") + self.assertEqual(parsed["traceFlags"], 0) + + def test_empty_attributes(self): + data = _make_log_data(attributes={}) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["attributes"], {}) + + def test_numeric_attribute_values_coerced_to_string(self): + data = _make_log_data(attributes={"count": 42, "enabled": True}) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["attributes"]["count"], "42") + self.assertEqual(parsed["attributes"]["enabled"], "True") + + def test_multiple_log_records(self): + data1 = _make_log_data(body="first") + data2 = _make_log_data(body="second") + self.exporter.export([data1, data2]) + lines = self.buf.getvalue().strip().split("\n") + self.assertEqual(len(lines), 2) + self.assertEqual(json.loads(lines[0])["body"], "first") + self.assertEqual(json.loads(lines[1])["body"], "second") + + def test_export_empty_batch(self): result = self.exporter.export([]) + self.assertEqual(result, LogExportResult.SUCCESS) + self.assertEqual(self.buf.getvalue(), "") + + def test_shutdown_prevents_export(self): + self.exporter.shutdown() + data = _make_log_data() + result = self.exporter.export([data]) + self.assertEqual(result, LogExportResult.FAILURE) + self.assertEqual(self.buf.getvalue(), "") + + def test_empty_resource_and_scope(self): + data = _make_log_data( + resource=Resource(attributes={}), + scope=InstrumentationScope(name=""), + ) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["resource"]["attributes"], {}) + self.assertEqual(parsed["resource"]["schemaUrl"], "") + self.assertEqual(parsed["instrumentationScope"]["name"], "") + self.assertEqual(parsed["instrumentationScope"]["version"], "") + self.assertEqual(parsed["instrumentationScope"]["schemaUrl"], "") + + def test_timestamp_with_milliseconds(self): + # 1000000000 seconds + 123 milliseconds + nanos = 1000000000 * 1_000_000_000 + 123 * 1_000_000 + data = _make_log_data(timestamp=nanos) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40.123Z") + + def test_timestamp_trailing_zero_truncation(self): + # 1000000000 seconds + 100 milliseconds -> .1Z not .100Z + nanos = 1000000000 * 1_000_000_000 + 100 * 1_000_000 + data = _make_log_data(timestamp=nanos) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40.1Z") - # Verify result - self.assertEqual(result, LogRecordExportResult.SUCCESS) + def test_severity_text_uses_enum_name(self): + for sev, expected_name in [ + (SeverityNumber.TRACE, "TRACE"), + (SeverityNumber.DEBUG, "DEBUG"), + (SeverityNumber.INFO, "INFO"), + (SeverityNumber.WARN, "WARN"), + (SeverityNumber.ERROR, "ERROR"), + (SeverityNumber.FATAL, "FATAL"), + ]: + self.buf = io.StringIO() + self.exporter = CompactConsoleLogRecordExporter(out=self.buf) + data = _make_log_data(severity_number=sev) + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["severityText"], expected_name) + self.assertEqual(parsed["severityNumber"], sev.value) - # Verify print calls - mock_print.assert_not_called() # No records, no prints + def test_output_is_compact_single_line(self): + data = _make_log_data() + self.exporter.export([data]) + output = self._get_output() + self.assertNotIn("\n", output) + self.assertNotIn(" ", output) From 8baa9032ee32499c0354a5711d214ac864768781 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Thu, 9 Apr 2026 11:16:04 -0700 Subject: [PATCH 2/9] fix for failing Python PR build --- .../logs/test_compact_console_log_exporter.py | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py index 7d20f4e76..42e94b218 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import inspect import io import json import unittest @@ -13,21 +14,26 @@ from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.trace import TraceFlags -# Support both old (LogData) and new (ReadableLogRecord) SDK APIs +# SDK 1.40+ moved LogRecord to _internal and removed 'resource' from its constructor. +# ReadableLogRecord wraps LogRecord with resource + scope on 1.40+. +# Older SDKs use LogData for the same purpose and LogRecord accepts 'resource'. try: - from opentelemetry.sdk._logs import ReadableLogRecord as _ReadableLogRecord - - _HAS_READABLE_LOG_RECORD = True + from opentelemetry.sdk._logs import LogRecord except ImportError: - _HAS_READABLE_LOG_RECORD = False + from opentelemetry.sdk._logs._internal import LogRecord -from opentelemetry.sdk._logs import LogRecord +try: + from opentelemetry.sdk._logs import ReadableLogRecord +except ImportError: + ReadableLogRecord = None try: from opentelemetry.sdk._logs import LogData except ImportError: LogData = None +_LOG_RECORD_ACCEPTS_RESOURCE = "resource" in inspect.signature(LogRecord.__init__).parameters + def _make_log_data( body="Test log message", @@ -55,7 +61,7 @@ def _make_log_data( schema_url="https://opentelemetry.io/schemas/1.0.0", ) - log_record = LogRecord( + log_record_kwargs = dict( timestamp=timestamp, observed_timestamp=observed_timestamp, trace_id=trace_id, @@ -63,13 +69,15 @@ def _make_log_data( trace_flags=trace_flags, severity_number=severity_number, body=body, - resource=resource, attributes=attributes if attributes else None, ) + if _LOG_RECORD_ACCEPTS_RESOURCE: + log_record_kwargs["resource"] = resource + + log_record = LogRecord(**log_record_kwargs) - # Use ReadableLogRecord (1.40+) if available, otherwise LogData - if _HAS_READABLE_LOG_RECORD: - return _ReadableLogRecord( + if ReadableLogRecord is not None: + return ReadableLogRecord( log_record=log_record, resource=resource, instrumentation_scope=scope, @@ -97,9 +105,18 @@ def test_export_with_all_fields_set(self): # Validate all top-level fields are present expected_keys = [ - "resource", "body", "severityNumber", "severityText", - "attributes", "droppedAttributes", "timestamp", "observedTimestamp", - "traceId", "spanId", "traceFlags", "instrumentationScope", + "resource", + "body", + "severityNumber", + "severityText", + "attributes", + "droppedAttributes", + "timestamp", + "observedTimestamp", + "traceId", + "spanId", + "traceFlags", + "instrumentationScope", ] for key in expected_keys: self.assertIn(key, parsed, f"Missing key: {key}") @@ -119,7 +136,10 @@ def test_export_with_all_fields_set(self): # Validate nested objects self.assertIn("attributes", parsed["resource"]) self.assertEqual(parsed["resource"]["attributes"]["service.name"], "test-service") - self.assertEqual(parsed["resource"]["schemaUrl"], "https://opentelemetry.io/schemas/1.0.0") + self.assertEqual( + parsed["resource"]["schemaUrl"], + "https://opentelemetry.io/schemas/1.0.0", + ) scope = parsed["instrumentationScope"] self.assertEqual(scope["name"], "test-scope") @@ -216,7 +236,6 @@ def test_empty_resource_and_scope(self): self.assertEqual(parsed["instrumentationScope"]["schemaUrl"], "") def test_timestamp_with_milliseconds(self): - # 1000000000 seconds + 123 milliseconds nanos = 1000000000 * 1_000_000_000 + 123 * 1_000_000 data = _make_log_data(timestamp=nanos) self.exporter.export([data]) @@ -224,7 +243,6 @@ def test_timestamp_with_milliseconds(self): self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40.123Z") def test_timestamp_trailing_zero_truncation(self): - # 1000000000 seconds + 100 milliseconds -> .1Z not .100Z nanos = 1000000000 * 1_000_000_000 + 100 * 1_000_000 data = _make_log_data(timestamp=nanos) self.exporter.export([data]) From 74bdd9b5b51ea6e6a9ce5db344e9c1014effae5c Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Wed, 15 Apr 2026 10:37:23 -0700 Subject: [PATCH 3/9] Addressing comments --- .../logs/compact_console_log_exporter.py | 152 +++++++----------- .../logs/test_compact_console_log_exporter.py | 51 +++--- .../sample-apps/function/lambda_function.py | 24 +++ 3 files changed, 111 insertions(+), 116 deletions(-) diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py index f2f7c61b5..34972c7c3 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py @@ -4,10 +4,12 @@ import logging import re import sys -from datetime import datetime, timezone -from typing import IO, Optional, Sequence +from typing import IO, Sequence -from opentelemetry.sdk._logs.export import LogExportResult +try: + from opentelemetry.sdk._logs.export import LogRecordExportResult as LogExportResult +except ImportError: + from opentelemetry.sdk._logs.export import LogExportResult # Support both old (LogData/LogExporter) and new (ReadableLogRecord/LogRecordExporter) APIs try: @@ -22,12 +24,27 @@ _logger = logging.getLogger(__name__) +def _preserve_attrs(attributes) -> dict: + """Preserve attribute value types (int, float, bool, str, list).""" + if not attributes: + return {} + return dict(attributes) + + +def _get_dropped_attrs(data, record) -> int: + """Extract dropped attributes count from whichever object has it.""" + if hasattr(data, "dropped_attributes"): + return data.dropped_attributes or 0 + if hasattr(record, "dropped_attributes"): + return record.dropped_attributes or 0 + return 0 + + class CompactConsoleLogRecordExporter(_BASE_CLASS): """Exports log records as compact JSON to stdout. - Produces a single-line JSON object per log record matching the canonical - schema shared across all ADOT language implementations. This exporter is - used in AWS Lambda environments when OTEL_LOGS_EXPORTER=console. + Produces a single-line JSON object per log record aligned with the + CloudWatch OTLP backend's flattened JSON format. If the standardized serialization fails for any reason, falls back to the upstream SDK's to_json() format to avoid breaking existing infrastructure. @@ -44,12 +61,16 @@ def export(self, batch: Sequence) -> LogExportResult: for data in batch: try: line = self._to_compact_json(data) - except Exception: + except Exception: # pylint: disable=broad-exception-caught _logger.debug( - "Failed to serialize log record with standardized format, falling back to upstream SDK", + "Failed to serialize log record, falling back", exc_info=True, ) - line = self._fallback_format(data) + try: + line = self._fallback_format(data) + except Exception: # pylint: disable=broad-exception-caught + _logger.debug("Fallback also failed", exc_info=True) + continue self._out.write(line + "\n") self._out.flush() @@ -59,77 +80,42 @@ def export(self, batch: Sequence) -> LogExportResult: def shutdown(self): self._shutdown = True - def _to_compact_json(self, data) -> str: - # Support both ReadableLogRecord (1.40+) and LogData (older) APIs. - # ReadableLogRecord: .log_record, .resource, .instrumentation_scope - # LogData: .log_record, .instrumentation_scope (resource on log_record) + @staticmethod + def _to_compact_json(data) -> str: + # Support both ReadableLogRecord (1.39+) and LogData (older) APIs. record = data.log_record resource = getattr(data, "resource", None) or getattr(record, "resource", None) scope = getattr(data, "instrumentation_scope", None) - # Resource - resource_attrs = {} - if resource and resource.attributes: - for k, v in resource.attributes.items(): - resource_attrs[k] = str(v) - resource_schema_url = "" - if resource and hasattr(resource, "schema_url") and resource.schema_url: - resource_schema_url = resource.schema_url - - # Span context validity: both trace_id and span_id must be non-zero trace_id = getattr(record, "trace_id", None) span_id = getattr(record, "span_id", None) - trace_id_valid = trace_id is not None and span_id is not None and trace_id != 0 and span_id != 0 - - # Attributes — coerce all values to strings - attrs = {} - if record.attributes: - for k, v in record.attributes.items(): - attrs[k] = str(v) - - # Severity text from severity number enum name (matches OTel spec names) - severity_text = record.severity_number.name if record.severity_number is not None else "UNSPECIFIED" - severity_number = record.severity_number.value if record.severity_number is not None else 0 - - # Instrumentation scope - scope_name = "" - scope_version = "" - scope_schema_url = "" - if scope: - scope_name = getattr(scope, "name", "") or "" - scope_version = getattr(scope, "version", "") or "" - scope_schema_url = getattr(scope, "schema_url", "") or "" - - # Dropped attributes - dropped = 0 - if hasattr(data, "dropped_attributes"): - dropped = data.dropped_attributes - elif hasattr(record, "dropped_attributes"): - dropped = record.dropped_attributes - - output = { - "resource": { - "attributes": resource_attrs, - "schemaUrl": resource_schema_url, - }, - "body": record.body if record.body is not None else None, - "severityNumber": severity_number, - "severityText": severity_text, - "attributes": attrs, - "droppedAttributes": dropped, - "timestamp": _format_nanos(record.timestamp), - "observedTimestamp": _format_nanos(record.observed_timestamp), - "traceId": format(trace_id, "032x") if trace_id_valid else "", - "spanId": format(span_id, "016x") if trace_id_valid else "", - "traceFlags": int(record.trace_flags) if record.trace_flags is not None else 0, - "instrumentationScope": { - "name": scope_name, - "version": scope_version, - "schemaUrl": scope_schema_url, + is_valid = trace_id is not None and span_id is not None and trace_id != 0 and span_id != 0 + + return json.dumps( + { + "resource": { + "attributes": _preserve_attrs(resource.attributes if resource else None), + "schemaUrl": getattr(resource, "schema_url", "") or "" if resource else "", + }, + "scope": { + "name": getattr(scope, "name", "") or "" if scope else "", + "version": getattr(scope, "version", "") or "" if scope else "", + "schemaUrl": getattr(scope, "schema_url", "") or "" if scope else "", + }, + "body": record.body if record.body is not None else None, + "severityNumber": (record.severity_number.value if record.severity_number is not None else 0), + "severityText": (record.severity_number.name if record.severity_number is not None else "UNSPECIFIED"), + "attributes": _preserve_attrs(record.attributes), + "droppedAttributes": _get_dropped_attrs(data, record), + "timeUnixNano": record.timestamp or 0, + "observedTimeUnixNano": (record.observed_timestamp or 0), + "traceId": format(trace_id, "032x") if is_valid else "", + "spanId": format(span_id, "016x") if is_valid else "", + "flags": int(record.trace_flags) if record.trace_flags is not None else 0, + "exportPath": "console", }, - } - - return json.dumps(output, separators=(",", ":")) + separators=(",", ":"), + ) @staticmethod def _fallback_format(data) -> str: @@ -138,23 +124,3 @@ def _fallback_format(data) -> str: obj = data if hasattr(data, "to_json") else data.log_record formatted_json = obj.to_json() return re.sub(r"\s*([{}[\]:,])\s*", r"\1", formatted_json) - - -def _format_nanos(nanos) -> Optional[str]: - """Convert epoch nanoseconds to ISO-8601 UTC string with trailing zero truncation. - - Matches Java's DateTimeFormatter.ISO_INSTANT behavior: - - 2001-09-09T01:46:40Z (no fractional seconds when millis == 0) - - 2001-09-09T01:46:40.1Z (truncated trailing zeros) - - 2001-09-09T01:46:40.12Z - - 2001-09-09T01:46:40.123Z - """ - if nanos is None or nanos == 0: - return None - millis = nanos // 1_000_000 - dt = datetime.fromtimestamp(millis / 1000, tz=timezone.utc) - frac_millis = millis % 1000 - if frac_millis == 0: - return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z" - frac = f".{frac_millis:03d}".rstrip("0") - return dt.strftime("%Y-%m-%dT%H:%M:%S") + frac + "Z" diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py index 42e94b218..f2d365089 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py @@ -9,7 +9,10 @@ CompactConsoleLogRecordExporter, ) from opentelemetry._logs import SeverityNumber -from opentelemetry.sdk._logs.export import LogExportResult +try: + from opentelemetry.sdk._logs.export import LogRecordExportResult as LogExportResult +except ImportError: + from opentelemetry.sdk._logs.export import LogExportResult from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.trace import TraceFlags @@ -106,17 +109,17 @@ def test_export_with_all_fields_set(self): # Validate all top-level fields are present expected_keys = [ "resource", + "scope", "body", "severityNumber", "severityText", "attributes", "droppedAttributes", - "timestamp", - "observedTimestamp", + "timeUnixNano", + "observedTimeUnixNano", "traceId", "spanId", - "traceFlags", - "instrumentationScope", + "flags", ] for key in expected_keys: self.assertIn(key, parsed, f"Missing key: {key}") @@ -127,11 +130,11 @@ def test_export_with_all_fields_set(self): self.assertEqual(parsed["severityText"], "INFO") self.assertEqual(parsed["attributes"], {"key": "value"}) self.assertEqual(parsed["droppedAttributes"], 0) - self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40Z") - self.assertEqual(parsed["observedTimestamp"], "2001-09-09T01:46:40Z") + self.assertEqual(parsed["timeUnixNano"], 1000000000 * 1_000_000_000) + self.assertEqual(parsed["observedTimeUnixNano"], 1000000000 * 1_000_000_000) self.assertEqual(parsed["traceId"], "12345678901234567890123456789012") self.assertEqual(parsed["spanId"], "1234567890123456") - self.assertEqual(parsed["traceFlags"], 1) + self.assertEqual(parsed["flags"], 1) # Validate nested objects self.assertIn("attributes", parsed["resource"]) @@ -141,7 +144,7 @@ def test_export_with_all_fields_set(self): "https://opentelemetry.io/schemas/1.0.0", ) - scope = parsed["instrumentationScope"] + scope = parsed["scope"] self.assertEqual(scope["name"], "test-scope") self.assertEqual(scope["version"], "1.0.0") self.assertEqual(scope["schemaUrl"], "https://opentelemetry.io/schemas/1.0.0") @@ -156,8 +159,8 @@ def test_zero_timestamps(self): data = _make_log_data(timestamp=0, observed_timestamp=0) self.exporter.export([data]) parsed = self._get_parsed() - self.assertIsNone(parsed["timestamp"]) - self.assertIsNone(parsed["observedTimestamp"]) + self.assertEqual(parsed["timeUnixNano"], 0) + self.assertEqual(parsed["observedTimeUnixNano"], 0) def test_invalid_span_context_all_zeros(self): data = _make_log_data(trace_id=0, span_id=0) @@ -186,7 +189,7 @@ def test_no_span_context(self): parsed = self._get_parsed() self.assertEqual(parsed["traceId"], "") self.assertEqual(parsed["spanId"], "") - self.assertEqual(parsed["traceFlags"], 0) + self.assertEqual(parsed["flags"], 0) def test_empty_attributes(self): data = _make_log_data(attributes={}) @@ -194,12 +197,14 @@ def test_empty_attributes(self): parsed = self._get_parsed() self.assertEqual(parsed["attributes"], {}) - def test_numeric_attribute_values_coerced_to_string(self): - data = _make_log_data(attributes={"count": 42, "enabled": True}) + def test_attribute_values_preserve_types(self): + data = _make_log_data(attributes={"count": 42, "enabled": True, "rate": 3.14, "name": "test"}) self.exporter.export([data]) parsed = self._get_parsed() - self.assertEqual(parsed["attributes"]["count"], "42") - self.assertEqual(parsed["attributes"]["enabled"], "True") + self.assertEqual(parsed["attributes"]["count"], 42) + self.assertEqual(parsed["attributes"]["enabled"], True) + self.assertEqual(parsed["attributes"]["rate"], 3.14) + self.assertEqual(parsed["attributes"]["name"], "test") def test_multiple_log_records(self): data1 = _make_log_data(body="first") @@ -231,23 +236,23 @@ def test_empty_resource_and_scope(self): parsed = self._get_parsed() self.assertEqual(parsed["resource"]["attributes"], {}) self.assertEqual(parsed["resource"]["schemaUrl"], "") - self.assertEqual(parsed["instrumentationScope"]["name"], "") - self.assertEqual(parsed["instrumentationScope"]["version"], "") - self.assertEqual(parsed["instrumentationScope"]["schemaUrl"], "") + self.assertEqual(parsed["scope"]["name"], "") + self.assertEqual(parsed["scope"]["version"], "") + self.assertEqual(parsed["scope"]["schemaUrl"], "") - def test_timestamp_with_milliseconds(self): + def test_timestamp_raw_nanos(self): nanos = 1000000000 * 1_000_000_000 + 123 * 1_000_000 data = _make_log_data(timestamp=nanos) self.exporter.export([data]) parsed = self._get_parsed() - self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40.123Z") + self.assertEqual(parsed["timeUnixNano"], nanos) - def test_timestamp_trailing_zero_truncation(self): + def test_timestamp_preserves_full_precision(self): nanos = 1000000000 * 1_000_000_000 + 100 * 1_000_000 data = _make_log_data(timestamp=nanos) self.exporter.export([data]) parsed = self._get_parsed() - self.assertEqual(parsed["timestamp"], "2001-09-09T01:46:40.1Z") + self.assertEqual(parsed["timeUnixNano"], nanos) def test_severity_text_uses_enum_name(self): for sev, expected_name in [ diff --git a/lambda-layer/sample-apps/function/lambda_function.py b/lambda-layer/sample-apps/function/lambda_function.py index c631abb86..bf0abad63 100644 --- a/lambda-layer/sample-apps/function/lambda_function.py +++ b/lambda-layer/sample-apps/function/lambda_function.py @@ -1,14 +1,38 @@ import json +import logging import os import boto3 import requests +from opentelemetry._logs import get_logger, LogRecord, SeverityNumber + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) +otel_logger = get_logger(__name__) client = boto3.client("s3") # lambda function def lambda_handler(event, context): + logger.debug("debug-level-test-message") + logger.info("info-level-test-message") + logger.warning("warn-level-test-message") + logger.error("error-level-test-message") + + # Test all attribute data types via OTel Logs API directly + otel_logger.emit( + LogRecord( + body="type-test-message", + severity_number=SeverityNumber.INFO, + attributes={ + "bool_attr": True, + "float_attr": 3.14, + "int_attr": 42, + "string_attr": "hello", + }, + ) + ) requests.get("https://aws.amazon.com/") From 1d7d4af0ece33c3ec1ab86d02f0478990ef8f85c Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Wed, 15 Apr 2026 10:41:56 -0700 Subject: [PATCH 4/9] Adding Changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2c1bc64..f0b094d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t ## Unreleased +- -fix(lambda-layer): Standardize CompactConsoleLogRecordExporter output with CloudWatch OTLP backend schema. + ([#715](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/715)) - fix(lambda-layer): Disable all agentic instrumentation in Lambda by default ([#710](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/710)) - fix(genai-instrumentors): cleanup code, align with OTel GenAI semconv, add missing attributes and fix deprecated usage From 77dd81a3c5120d4f4b5800590e87b8ed999f121c Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Wed, 15 Apr 2026 12:12:07 -0700 Subject: [PATCH 5/9] fixing linter and test coverage failure --- .../logs/test_compact_console_log_exporter.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py index f2d365089..398cbf682 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py @@ -4,15 +4,18 @@ import io import json import unittest +import unittest.mock from amazon.opentelemetry.distro.exporter.console.logs.compact_console_log_exporter import ( CompactConsoleLogRecordExporter, ) from opentelemetry._logs import SeverityNumber + try: from opentelemetry.sdk._logs.export import LogRecordExportResult as LogExportResult except ImportError: from opentelemetry.sdk._logs.export import LogExportResult + from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.trace import TraceFlags @@ -277,3 +280,57 @@ def test_output_is_compact_single_line(self): output = self._get_output() self.assertNotIn("\n", output) self.assertNotIn(" ", output) + + def test_fallback_on_serialization_error(self): + """If _to_compact_json fails, fallback should produce output.""" + data = _make_log_data() + with unittest.mock.patch.object( + CompactConsoleLogRecordExporter, + "_to_compact_json", + side_effect=ValueError("forced"), + ): + result = self.exporter.export([data]) + self.assertEqual(result, LogExportResult.SUCCESS) + output = self._get_output() + self.assertTrue(len(output) > 0) + + def test_fallback_also_fails_continues(self): + """If both _to_compact_json and _fallback_format fail, skip record.""" + data = _make_log_data() + with unittest.mock.patch.object( + CompactConsoleLogRecordExporter, + "_to_compact_json", + side_effect=ValueError("forced"), + ), unittest.mock.patch.object( + CompactConsoleLogRecordExporter, + "_fallback_format", + side_effect=ValueError("fallback forced"), + ): + result = self.exporter.export([data]) + self.assertEqual(result, LogExportResult.SUCCESS) + self.assertEqual(self.buf.getvalue(), "") + + def test_dropped_attributes_from_record(self): + """Test dropped_attributes extracted from record when data lacks it.""" + data = _make_log_data() + # Remove dropped_attributes from data if present, add to record + if hasattr(data, "log_record"): + data.log_record.dropped_attributes = 5 + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertIsInstance(parsed["droppedAttributes"], int) + + def test_dropped_attributes_defaults_to_zero(self): + """Test droppedAttributes is always an integer.""" + data = _make_log_data() + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertIsInstance(parsed["droppedAttributes"], int) + self.assertEqual(parsed["droppedAttributes"], 0) + + def test_export_path_field(self): + """Console exporter includes exportPath:console.""" + data = _make_log_data() + self.exporter.export([data]) + parsed = self._get_parsed() + self.assertEqual(parsed["exportPath"], "console") From 2c7b29bf72f3e1296c6e66bb23bf8c4603446a41 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Wed, 15 Apr 2026 12:36:14 -0700 Subject: [PATCH 6/9] fixing lint error --- lambda-layer/sample-apps/function/lambda_function.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lambda-layer/sample-apps/function/lambda_function.py b/lambda-layer/sample-apps/function/lambda_function.py index bf0abad63..ff61a3641 100644 --- a/lambda-layer/sample-apps/function/lambda_function.py +++ b/lambda-layer/sample-apps/function/lambda_function.py @@ -4,7 +4,8 @@ import boto3 import requests -from opentelemetry._logs import get_logger, LogRecord, SeverityNumber + +from opentelemetry._logs import LogRecord, SeverityNumber, get_logger logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) From f578edc576726d347775f592b675b7fa64b7fcb2 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Thu, 14 May 2026 13:57:43 -0700 Subject: [PATCH 7/9] making export path console conditional --- .../console/logs/compact_console_log_exporter.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py index 34972c7c3..ae274c556 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import json import logging +import os import re import sys from typing import IO, Sequence @@ -95,12 +96,12 @@ def _to_compact_json(data) -> str: { "resource": { "attributes": _preserve_attrs(resource.attributes if resource else None), - "schemaUrl": getattr(resource, "schema_url", "") or "" if resource else "", + "schemaUrl": (getattr(resource, "schema_url", "") or "") if resource else "", }, "scope": { - "name": getattr(scope, "name", "") or "" if scope else "", - "version": getattr(scope, "version", "") or "" if scope else "", - "schemaUrl": getattr(scope, "schema_url", "") or "" if scope else "", + "name": (getattr(scope, "name", "") or "") if scope else "", + "version": (getattr(scope, "version", "") or "") if scope else "", + "schemaUrl": (getattr(scope, "schema_url", "") or "") if scope else "", }, "body": record.body if record.body is not None else None, "severityNumber": (record.severity_number.value if record.severity_number is not None else 0), @@ -112,7 +113,7 @@ def _to_compact_json(data) -> str: "traceId": format(trace_id, "032x") if is_valid else "", "spanId": format(span_id, "016x") if is_valid else "", "flags": int(record.trace_flags) if record.trace_flags is not None else 0, - "exportPath": "console", + **({"exportPath": "console"} if os.environ.get("ADOT_TEST_EXPORT_PATH_ENABLED") == "true" else {}), }, separators=(",", ":"), ) From f3f5b31b386aee514d48ccddae34c4d5fb406ff5 Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Thu, 14 May 2026 14:13:51 -0700 Subject: [PATCH 8/9] removing failing smoke test --- .../console/logs/test_compact_console_log_exporter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py index 398cbf682..d98efd548 100644 --- a/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/exporter/console/logs/test_compact_console_log_exporter.py @@ -328,9 +328,9 @@ def test_dropped_attributes_defaults_to_zero(self): self.assertIsInstance(parsed["droppedAttributes"], int) self.assertEqual(parsed["droppedAttributes"], 0) - def test_export_path_field(self): - """Console exporter includes exportPath:console.""" + def test_export_path_field_absent_when_env_unset(self): + """Console exporter omits exportPath when env var is not set.""" data = _make_log_data() self.exporter.export([data]) parsed = self._get_parsed() - self.assertEqual(parsed["exportPath"], "console") + self.assertNotIn("exportPath", parsed) From 0ed5cc691c633df5915037462e0b1071ce963e7b Mon Sep 17 00:00:00 2001 From: MIQUEAS HERRERA Date: Thu, 14 May 2026 15:10:46 -0700 Subject: [PATCH 9/9] fixing changelog entry --- CHANGELOG.md | 2 +- .../exporter/console/logs/compact_console_log_exporter.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1554382..a44d20105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t ## Unreleased -- -fix(lambda-layer): Standardize CompactConsoleLogRecordExporter output with CloudWatch OTLP backend schema. +- fix(lambda-layer): Standardize CompactConsoleLogRecordExporter output with CloudWatch OTLP backend schema. ([#715](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/715)) - fix(agent-observability): fall back to OTEL_EXPORTER_OTLP_ENDPOINT for unsampled spans; also export unsampled spans to non-AWS endpoints ([#738](https://github.com/aws-observability/aws-otel-python-instrumentation/pull/738)) diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py index ae274c556..d0e7f01f5 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/console/logs/compact_console_log_exporter.py @@ -79,6 +79,7 @@ def export(self, batch: Sequence) -> LogExportResult: return LogExportResult.SUCCESS def shutdown(self): + self._out.flush() self._shutdown = True @staticmethod