From 1f58ebe624bbc083a118e3421a7e7fe078f54f99 Mon Sep 17 00:00:00 2001 From: Brian Marks Date: Thu, 23 Jul 2026 19:35:11 -0400 Subject: [PATCH 1/2] test(parametric): cover OTLP export startup-log fields Extend Test_Startup_Logs to assert the three OTLP-export booleans every tracer now publishes in its startup diagnostic configuration: otlp_traces_export_enabled, otlp_metrics_export_enabled, otlp_logs_export_enabled. Add a cross-language extraction helper that locates the config line by field name and reads each boolean with a quote-style- and casing-tolerant regex. This tolerates the per-tracer serialization differences: JSON with a " - " marker (Node.js, .NET, Ruby "CORE" section), JSON with no separator (Go, Java), and Python's dict repr (True/False, single quotes) which is not valid JSON. Requiring an unquoted true/false/True/False keeps the assertion that each field is a real boolean. New tests: - test_startup_logs_otlp_export_fields: all three present, boolean, and false by default (no OTLP env vars). - test_startup_logs_otlp_traces_export_enabled: OTEL_TRACES_EXPORTER=otlp. - test_startup_logs_otlp_metrics_export_enabled: DD_METRICS_OTEL_ENABLED=true (+ DD_RUNTIME_METRICS_ENABLED for .NET). - test_startup_logs_otlp_logs_export_enabled: DD_LOGS_OTEL_ENABLED=true. Manifest gating uses per-language placeholder next-version floors (the tracer changes are unreleased). Ruby's traces test is marked irrelevant (no OTLP trace exporter), PHP incomplete_test_app, Rust missing_feature, consistent with the existing startup-log entries. NOTE: version pins are placeholders that MUST be confirmed before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- manifests/dotnet.yml | 4 + manifests/golang.yml | 4 + manifests/java.yml | 4 + manifests/nodejs.yml | 5 ++ manifests/php.yml | 4 + manifests/python.yml | 4 + manifests/ruby.yml | 4 + manifests/rust.yml | 4 + tests/parametric/test_startup_logs.py | 125 ++++++++++++++++++++++++++ 9 files changed, 158 insertions(+) diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 02de540e07d..e89d509866d 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -1049,6 +1049,10 @@ manifest: component_version: <3.36.0 ? tests/parametric/test_span_sampling.py::Test_Span_Sampling::test_root_span_selected_and_child_dropped_by_sss_when_dropping_policy_is_active016 : missing_feature (The .NET tracer sends the full trace to the agent anyways.) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: v3.50.0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: v3.50.0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: v3.50.0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: v3.50.0 tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Defaults: v2.49.0 tests/parametric/test_telemetry.py::Test_Defaults::test_library_settings: diff --git a/manifests/golang.yml b/manifests/golang.yml index 3d689f448e9..7b351574f12 100644 --- a/manifests/golang.yml +++ b/manifests/golang.yml @@ -1510,6 +1510,10 @@ manifest: tests/parametric/test_span_sampling.py::Test_Span_Sampling::test_single_rule_with_head_and_rule_trace_sampling_keep_019: # Modified by easy win activation script - declaration: bug (APMAPI-1545) component_version: <2.5.0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: v2.11.0-dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: v2.11.0-dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: v2.11.0-dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: v2.11.0-dev tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature (APMAPI-745) tests/parametric/test_telemetry.py::Test_Defaults: missing_feature tests/parametric/test_telemetry.py::Test_Environment: missing_feature diff --git a/manifests/java.yml b/manifests/java.yml index cffb404c18b..9ee540ffea6 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -3974,6 +3974,10 @@ manifest: tests/parametric/test_span_sampling.py::Test_Span_Sampling::test_single_rule_with_head_and_rule_trace_sampling_drop_020: - component_version: ">=1.57.0" declaration: flaky (APMAPI-1785) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: v1.65.0-SNAPSHOT + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: v1.65.0-SNAPSHOT + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: v1.65.0-SNAPSHOT + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: v1.65.0-SNAPSHOT tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Defaults: v1.61.0-SNAPSHOT # Normalization of telemetry keys updated in https://github.com/DataDog/dd-trace-java/pull/10823 tests/parametric/test_telemetry.py::Test_Environment: v1.61.0-SNAPSHOT # Normalization of telemetry keys updated in https://github.com/DataDog/dd-trace-java/pull/10823 diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index 1358279480a..7d32d929646 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -102,6 +102,7 @@ refs: - &ref_5_111_0 '>=5.111.0' - &ref_6_0_0 '>=6.0.0-pre' - &ref_6_5_0 '>=6.5.0 || ^5.116.0' + - &ref_7_0_0 '>=7.0.0-pre' manifest: tests/ai_guard/test_ai_guard_sdk.py::Test_AIGuardEvent_Tag: - weblog_declaration: @@ -2250,6 +2251,10 @@ manifest: : missing_feature (Not implemented) tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_default: *ref_6_0_0 tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_diagnostic_agent_unreachable: missing_feature (Diagnostic messages not emitted when agent is unreachable) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: *ref_7_0_0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: *ref_7_0_0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: *ref_7_0_0 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: *ref_7_0_0 tests/parametric/test_telemetry.py::Test_Consistent_Configs: *ref_5_25_0 tests/parametric/test_telemetry.py::Test_Consistent_Configs::test_library_settings_2: missing_feature (Not implemented) tests/parametric/test_telemetry.py::Test_Defaults: *ref_5_6_0 diff --git a/manifests/php.yml b/manifests/php.yml index 460f1f38407..f957e7d22d2 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -1087,6 +1087,10 @@ manifest: tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_default: incomplete_test_app (Not currently testable) tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_diagnostic_agent_unreachable: missing_feature (Diagnostic messages not emitted when agent is unreachable) tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_enabled: incomplete_test_app (Not currently testable) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: incomplete_test_app (Not currently testable) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: incomplete_test_app (Not currently testable) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: incomplete_test_app (Not currently testable) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: incomplete_test_app (Not currently testable) tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Defaults: missing_feature tests/parametric/test_telemetry.py::Test_Environment: missing_feature diff --git a/manifests/python.yml b/manifests/python.yml index f45bd4aaa41..7a3e4313049 100644 --- a/manifests/python.yml +++ b/manifests/python.yml @@ -2044,6 +2044,10 @@ manifest: tests/parametric/test_span_sampling.py::Test_Span_Sampling::test_single_rule_with_head_and_rule_trace_sampling_keep_019: # Modified by easy win activation script - declaration: bug (APMAPI-1545) component_version: <4.3.1 + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: v4.13.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: v4.13.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: v4.13.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: v4.13.0.dev tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Consistent_Configs::test_library_settings: - declaration: missing_feature (Reports configurations with unexpected names) diff --git a/manifests/ruby.yml b/manifests/ruby.yml index 5e455d4db15..4167c1b1bd5 100644 --- a/manifests/ruby.yml +++ b/manifests/ruby.yml @@ -2442,6 +2442,10 @@ manifest: - declaration: bug (APMAPI-1545) component_version: <2.20.0 tests/parametric/test_span_sampling.py::Test_Span_Sampling::test_special_glob_characters_span_sampling_sss002: "missing_feature (\"Issue: _dd.span_sampling.max_per_second is always set in Ruby\")" + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: v2.40.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: v2.40.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: v2.40.0.dev + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: irrelevant (Ruby exports spans in Datadog native format and has no OTLP trace exporter) tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Defaults: missing_feature tests/parametric/test_telemetry.py::Test_Environment: missing_feature diff --git a/manifests/rust.yml b/manifests/rust.yml index 7b4962e860f..8ecdedc662f 100644 --- a/manifests/rust.yml +++ b/manifests/rust.yml @@ -317,6 +317,10 @@ manifest: tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_default: missing_feature (Startup logs not implemented) tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_diagnostic_agent_unreachable: missing_feature (Diagnostic messages not implemented) tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_enabled: missing_feature (Startup logs not implemented) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_export_fields: missing_feature (Startup logs not implemented) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_logs_export_enabled: missing_feature (Startup logs not implemented) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_metrics_export_enabled: missing_feature (Startup logs not implemented) + tests/parametric/test_startup_logs.py::Test_Startup_Logs::test_startup_logs_otlp_traces_export_enabled: missing_feature (Startup logs not implemented) tests/parametric/test_telemetry.py::Test_Consistent_Configs: missing_feature tests/parametric/test_telemetry.py::Test_Defaults: missing_feature tests/parametric/test_telemetry.py::Test_Environment: missing_feature diff --git a/tests/parametric/test_startup_logs.py b/tests/parametric/test_startup_logs.py index e50399b3e73..5a7d2f7c9b7 100644 --- a/tests/parametric/test_startup_logs.py +++ b/tests/parametric/test_startup_logs.py @@ -12,6 +12,52 @@ # Regex pattern for matching startup log entries across all tracer libraries STARTUP_LOG_PATTERN = r"DATADOG (TRACER )?CONFIGURATION( - (CORE|TRACING|PROFILING|.*))?" +# Boolean fields every tracer publishes in its startup diagnostic configuration describing +# whether traces/metrics/logs are exported over OTLP. +OTLP_EXPORT_FIELDS = ( + "otlp_traces_export_enabled", + "otlp_metrics_export_enabled", + "otlp_logs_export_enabled", +) + + +def _extract_otlp_export_fields(logs: str) -> dict[str, bool]: + """Extract the three OTLP-export booleans from the tracer startup configuration line. + + The startup diagnostic is NOT serialized uniformly across tracers: + - JSON, " - " marker separator: Node.js ("DATADOG TRACER CONFIGURATION - {json}"), + .NET ("DATADOG TRACER CONFIGURATION - {json}"), Ruby ("DATADOG CONFIGURATION - CORE - {json}"). + - JSON, no " - " separator: Go ("DATADOG TRACER CONFIGURATION {json}"), + Java (SLF4J "DATADOG TRACER CONFIGURATION {json}"). + - Python dict repr, NOT JSON: "- DATADOG TRACER CONFIGURATION - {dict}" produced by + "%s" % dict, so single-quoted keys and True/False/None -- json.loads would fail on it. + + Rather than depend on a single per-language marker or full-object decoding (Python's payload + is not valid JSON, and the JSON payloads embed nested objects that make a naive brace match + fragile), the config line is located by field name and each boolean is read directly with a + quote-style- and casing-tolerant regex. An UNQUOTED true/false/True/False is required, so a + string-encoded value such as "true" would not match -- this preserves the guarantee that each + field is emitted as a real boolean. + """ + config_line = None + for line in logs.splitlines(): + if OTLP_EXPORT_FIELDS[0] in line: + config_line = line + break + assert config_line is not None, ( + f"No tracer startup configuration line containing '{OTLP_EXPORT_FIELDS[0]}' found. " + f"Logs (first 2000 chars): {logs[:2000]}" + ) + + fields: dict[str, bool] = {} + for name in OTLP_EXPORT_FIELDS: + match = re.search(rf"""["']?{name}["']?\s*:\s*(true|false|True|False)\b""", config_line) + assert match, ( + f"Field '{name}' not present as a boolean in the startup configuration line. Config line: {config_line}" + ) + fields[name] = match.group(1).lower() == "true" + return fields + def _get_dotnet_startup_logs(test_library: APMLibrary, *, required: bool = True) -> str | None: """Get .NET tracer startup logs from the container (dotnet-tracer-managed* file). @@ -96,6 +142,85 @@ def test_startup_logs_enabled(self, test_library: APMLibrary): f"Content (first 2000 chars): {logs[:2000]}" ) + @parametrize( + "library_env", + [{"DD_TRACE_STARTUP_LOGS": "true"}], + ) + def test_startup_logs_otlp_export_fields(self, test_library: APMLibrary): + """Verify the OTLP-export booleans are present, are booleans, and default to false. + + No OTLP-related environment variables are set, so every implementing tracer must report + all three fields as False. DD_TRACE_STARTUP_LOGS=true only guarantees the diagnostic is + emitted; it does not affect the OTLP-export values. Uniform across implementing languages. + """ + with test_library: + logs = _get_startup_logs(test_library, required=True) + + assert logs is not None + fields = _extract_otlp_export_fields(logs) + for name in OTLP_EXPORT_FIELDS: + assert fields[name] is False, ( + f"Expected {name} to default to False (no OTLP env vars set), got {fields[name]!r}" + ) + + @parametrize( + "library_env", + [{"DD_TRACE_STARTUP_LOGS": "true", "OTEL_TRACES_EXPORTER": "otlp"}], + ) + def test_startup_logs_otlp_traces_export_enabled(self, test_library: APMLibrary): + """Verify otlp_traces_export_enabled is true when OTEL_TRACES_EXPORTER=otlp. + + Gated (via manifests) to the tracers that support OTLP trace export. Ruby and PHP do not + support it, so they are marked irrelevant/incomplete rather than skipped in the body. + """ + with test_library: + logs = _get_startup_logs(test_library, required=True) + + assert logs is not None + fields = _extract_otlp_export_fields(logs) + assert fields["otlp_traces_export_enabled"] is True, ( + f"Expected otlp_traces_export_enabled to be True with OTEL_TRACES_EXPORTER=otlp, " + f"got {fields['otlp_traces_export_enabled']!r}" + ) + + @parametrize( + "library_env", + [ + { + "DD_TRACE_STARTUP_LOGS": "true", + "DD_METRICS_OTEL_ENABLED": "true", + "DD_RUNTIME_METRICS_ENABLED": "true", # .NET only flips OTLP metrics when runtime metrics are on + } + ], + ) + def test_startup_logs_otlp_metrics_export_enabled(self, test_library: APMLibrary): + """Verify otlp_metrics_export_enabled is true when DD_METRICS_OTEL_ENABLED=true.""" + with test_library: + logs = _get_startup_logs(test_library, required=True) + + assert logs is not None + fields = _extract_otlp_export_fields(logs) + assert fields["otlp_metrics_export_enabled"] is True, ( + f"Expected otlp_metrics_export_enabled to be True with DD_METRICS_OTEL_ENABLED=true, " + f"got {fields['otlp_metrics_export_enabled']!r}" + ) + + @parametrize( + "library_env", + [{"DD_TRACE_STARTUP_LOGS": "true", "DD_LOGS_OTEL_ENABLED": "true"}], + ) + def test_startup_logs_otlp_logs_export_enabled(self, test_library: APMLibrary): + """Verify otlp_logs_export_enabled is true when DD_LOGS_OTEL_ENABLED=true.""" + with test_library: + logs = _get_startup_logs(test_library, required=True) + + assert logs is not None + fields = _extract_otlp_export_fields(logs) + assert fields["otlp_logs_export_enabled"] is True, ( + f"Expected otlp_logs_export_enabled to be True with DD_LOGS_OTEL_ENABLED=true, " + f"got {fields['otlp_logs_export_enabled']!r}" + ) + @parametrize( "library_env", [ From 4dede7f1a2f05cc1ec6295f506a44481dae9af1a Mon Sep 17 00:00:00 2001 From: Brian Marks Date: Thu, 23 Jul 2026 21:09:46 -0400 Subject: [PATCH 2/2] test(parametric): bound the startup-log stderr read so a runaway stream can't hang _get_startup_logs read the entire container stderr in one tail-less container.logs() call. If a tracer misbehaves and emits an ever-growing stderr stream (e.g. a logging feedback loop), that read chases a moving target and never returns, hanging the whole parametric suite. Make the non-.NET read bounded and defensive: - Stream the current stderr snapshot (stream=True, follow=False), which self-terminates at end-of-snapshot in normal operation, so behavior is unchanged when the tracer is well-behaved. - Cap the read by line count, byte count, and wall-clock time; the first cap reached stops the read and returns what was accumulated. This bounds both the runaway case and the no-marker "disabled" case. - Accumulate raw byte chunks (docker log frames, not lines) and decode once at the end so a line split across chunks is handled, using errors="replace" for a multi-byte char truncated at a cap boundary. - Close the stream in a finally so a mid-snapshot stop releases the socket. Read the whole bounded snapshot rather than early-exiting at the first startup-config marker: some tracers emit content the tests assert on after that marker (Ruby logs "CONFIGURATION - CORE" at init, then "CONFIGURATION - TRACING" and its "Agent Error" line only after a flush), so stopping at the first marker would truncate content the OTLP-field and diagnostic tests depend on. The startup logs are emitted early, so the caps never truncate them in normal runs. The .NET on-disk managed-log path and the stderr-only stream selection are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/parametric/test_startup_logs.py | 68 +++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/tests/parametric/test_startup_logs.py b/tests/parametric/test_startup_logs.py index 5a7d2f7c9b7..9a15acef9a7 100644 --- a/tests/parametric/test_startup_logs.py +++ b/tests/parametric/test_startup_logs.py @@ -1,6 +1,8 @@ """Test startup log behavior for tracer libraries across supported languages.""" +import contextlib import re +import time import pytest @@ -12,6 +14,13 @@ # Regex pattern for matching startup log entries across all tracer libraries STARTUP_LOG_PATTERN = r"DATADOG (TRACER )?CONFIGURATION( - (CORE|TRACING|PROFILING|.*))?" +# Defense-in-depth caps for the container stderr read in _get_startup_logs: whichever limit is +# hit first stops the read, so an unbounded stream can't hang the suite. All are far above any +# real tracer's startup output, so they never truncate logs in normal operation. +_STARTUP_LOG_MAX_LINES = 50_000 +_STARTUP_LOG_MAX_BYTES = 10 * 1024 * 1024 # 10 MiB +_STARTUP_LOG_MAX_SECONDS = 30.0 + # Boolean fields every tracer publishes in its startup diagnostic configuration describing # whether traces/metrics/logs are exported over OTLP. OTLP_EXPORT_FIELDS = ( @@ -97,15 +106,58 @@ def _get_startup_logs(test_library: APMLibrary, *, required: bool = True) -> str """ if context.library == "dotnet": return _get_dotnet_startup_logs(test_library, required=required) - else: - try: - logs = test_library.container.logs(stderr=True, stdout=False).decode("utf-8") - except Exception as e: - if required: - pytest.fail(f"Failed to retrieve container logs: {e}") - return None - return logs + # Bound the stderr read so a misbehaving tracer emitting an unbounded stream (e.g. a logging + # feedback loop) can't hang the suite -- a one-shot container.logs() read would chase that + # moving target forever. Stream the current snapshot (follow=False self-terminates at its end) + # and stop at the first of the line/byte/time caps. + # + # Read the whole bounded snapshot rather than stopping at the first startup-config marker: + # some tracers emit asserted-on content after it (Ruby logs "CONFIGURATION - CORE" at init, + # then "CONFIGURATION - TRACING" and its "Agent Error" line only after a flush). Startup logs + # are early, so the caps never truncate them in normal runs. + log_stream = None + truncated = False + buffer = bytearray() + line_count = 0 + try: + # stream=True yields raw byte chunks (docker log frames), not lines; a line may span + # chunks, so we accumulate bytes and decode once at the end. + log_stream = test_library.container.logs(stream=True, stdout=False, stderr=True, follow=False) + + deadline = time.monotonic() + _STARTUP_LOG_MAX_SECONDS + for chunk in log_stream: + if not chunk: + continue + buffer += chunk + line_count += chunk.count(b"\n") + if ( + line_count >= _STARTUP_LOG_MAX_LINES + or len(buffer) >= _STARTUP_LOG_MAX_BYTES + or time.monotonic() >= deadline + ): + truncated = True + break + except Exception as e: + if required: + pytest.fail(f"Failed to retrieve container logs: {e}") + return None + finally: + # Release the streaming socket promptly; we may have stopped mid-snapshot at a cap. + if log_stream is not None: + with contextlib.suppress(Exception): + log_stream.close() + + if truncated: + # Never truncate silently: a cap only trips if a tracer is emitting an abnormally large + # (likely runaway) stderr stream, which is worth surfacing in CI output. + logger.warning( + f"Startup-log read hit a safety cap and was truncated at {line_count} lines / " + f"{len(buffer)} bytes; a tracer may be emitting an unbounded stderr stream." + ) + + # errors="replace" guards against a multi-byte character truncated at a cap boundary. + return buffer.decode("utf-8", errors="replace") @scenarios.parametric