diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c462112a7..8e5a8b342d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,9 @@ Increment the: * [CI] iwyu and clang-tidy: use install_thirdparty.sh for third-party [#4136](https://github.com/open-telemetry/opentelemetry-cpp/pull/4136) +* [SDK] LogRecord attribute limits enforcement + [#4157](https://github.com/open-telemetry/opentelemetry-cpp/pull/4157) + * [CONFIGURATION] File configuration: declarative resource detection types [#4148](https://github.com/open-telemetry/opentelemetry-cpp/pull/4148) diff --git a/exporters/ostream/include/opentelemetry/exporters/ostream/log_record_exporter.h b/exporters/ostream/include/opentelemetry/exporters/ostream/log_record_exporter.h index 840d7d5b8a..f0bd0dc39e 100644 --- a/exporters/ostream/include/opentelemetry/exporters/ostream/log_record_exporter.h +++ b/exporters/ostream/include/opentelemetry/exporters/ostream/log_record_exporter.h @@ -37,6 +37,13 @@ class OStreamLogRecordExporter final : public opentelemetry::sdk::logs::LogRecor std::unique_ptr MakeRecordable() noexcept override; + /** + * The ostream exporter uses ReadWriteLogRecord, which applies the configured + * LogRecord attribute limits, so the SDK should push the limits onto each + * record it produces. + */ + bool RecordableEnforcesLogRecordLimits() const noexcept override { return true; } + /** * Exports a span of logs sent from the processor. */ diff --git a/exporters/otlp/BUILD b/exporters/otlp/BUILD index e28ba407b2..86b26c57be 100644 --- a/exporters/otlp/BUILD +++ b/exporters/otlp/BUILD @@ -666,6 +666,23 @@ cc_test( ], ) +cc_test( + name = "otlp_populate_attribute_utils_test", + srcs = [ + "test/otlp_populate_attribute_utils_test.cc", + ], + tags = [ + "otlp", + "test", + ], + deps = [ + ":otlp_recordable", + "//sdk/src/metrics", + "@com_github_opentelemetry_proto//:common_proto_cc", + "@com_google_googletest//:gtest_main", + ], +) + cc_test( name = "otlp_file_client_test", srcs = ["test/otlp_file_client_test.cc"], diff --git a/exporters/otlp/CMakeLists.txt b/exporters/otlp/CMakeLists.txt index 7cbe414785..89e229ca4a 100644 --- a/exporters/otlp/CMakeLists.txt +++ b/exporters/otlp/CMakeLists.txt @@ -892,6 +892,16 @@ if(BUILD_TESTING) TEST_PREFIX exporter.otlp. TEST_LIST otlp_log_recordable_test) + add_executable(otlp_populate_attribute_utils_test + test/otlp_populate_attribute_utils_test.cc) + target_link_libraries( + otlp_populate_attribute_utils_test ${GTEST_BOTH_LIBRARIES} + ${CMAKE_THREAD_LIBS_INIT} opentelemetry_otlp_recordable) + gtest_add_tests( + TARGET otlp_populate_attribute_utils_test + TEST_PREFIX exporter.otlp. + TEST_LIST otlp_populate_attribute_utils_test) + add_executable(otlp_metrics_serialization_test test/otlp_metrics_serialization_test.cc) target_link_libraries( diff --git a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_file_log_record_exporter.h b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_file_log_record_exporter.h index 7742616351..3584a8b878 100644 --- a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_file_log_record_exporter.h +++ b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_file_log_record_exporter.h @@ -51,6 +51,12 @@ class OtlpFileLogRecordExporter final : public opentelemetry::sdk::logs::LogReco */ std::unique_ptr MakeRecordable() noexcept override; + /** + * The OTLP recordable applies the configured LogRecord attribute limits, so + * the SDK should push the limits onto each record it produces. + */ + bool RecordableEnforcesLogRecordLimits() const noexcept override { return true; } + /** * Exports a vector of log records to the Elasticsearch instance. Guaranteed to return after a * timeout specified from the options passed from the constructor. diff --git a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_grpc_log_record_exporter.h b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_grpc_log_record_exporter.h index 888c368b28..9b6dc5c115 100644 --- a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_grpc_log_record_exporter.h +++ b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_grpc_log_record_exporter.h @@ -68,6 +68,12 @@ class OtlpGrpcLogRecordExporter : public opentelemetry::sdk::logs::LogRecordExpo */ std::unique_ptr MakeRecordable() noexcept override; + /** + * The OTLP recordable applies the configured LogRecord attribute limits, so + * the SDK should push the limits onto each record it produces. + */ + bool RecordableEnforcesLogRecordLimits() const noexcept override { return true; } + /** * Exports a vector of log records to the configured gRPC endpoint. Guaranteed to return after a * timeout specified from the options passed to the constructor. diff --git a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_http_log_record_exporter.h b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_http_log_record_exporter.h index 4259c28998..7bcc98210e 100644 --- a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_http_log_record_exporter.h +++ b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_http_log_record_exporter.h @@ -90,6 +90,12 @@ class OtlpHttpLogRecordExporter final : public opentelemetry::sdk::logs::LogReco */ std::unique_ptr MakeRecordable() noexcept override; + /** + * The OTLP recordable applies the configured LogRecord attribute limits, so + * the SDK should push the limits onto each record it produces. + */ + bool RecordableEnforcesLogRecordLimits() const noexcept override { return true; } + /** * Exports a vector of log records to the Elasticsearch instance. Guaranteed to return after a * timeout specified from the options passed from the constructor. diff --git a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_log_recordable.h b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_log_recordable.h index efcb0d846b..1d7f774bd7 100644 --- a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_log_recordable.h +++ b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_log_recordable.h @@ -7,6 +7,7 @@ #include "opentelemetry/common/attribute_value.h" #include "opentelemetry/sdk/instrumentationscope/instrumentation_scope.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/recordable.h" #include "opentelemetry/sdk/resource/resource.h" #include "opentelemetry/version.h" @@ -96,6 +97,14 @@ class OtlpLogRecordable final : public opentelemetry::sdk::logs::Recordable void SetAttribute(nostd::string_view key, const opentelemetry::common::AttributeValue &value) noexcept override; + /** + * Apply attribute count and value length limits. Must be called before any + * SetAttribute call to take effect. The limits are copied into this + * recordable. + */ + void SetLogRecordLimits( + const opentelemetry::sdk::logs::LogRecordLimits &limits) noexcept override; + /** * Set Resource of this log * @param Resource the resource to set @@ -114,6 +123,12 @@ class OtlpLogRecordable final : public opentelemetry::sdk::logs::Recordable const opentelemetry::sdk::resource::Resource *resource_ = nullptr; const opentelemetry::sdk::instrumentationscope::InstrumentationScope *instrumentation_scope_ = nullptr; + // Stored by value so the recordable does not depend on the limits object + // outliving the LoggerContext that supplied it. Defaults to no limits; the + // LoggerProvider wiring injects the configured limits via SetLogRecordLimits, + // so a recordable used outside a provider does not cap attributes on its own. + opentelemetry::sdk::logs::LogRecordLimits limits_ = + opentelemetry::sdk::logs::LogRecordLimits::NoLimits(); }; } // namespace otlp diff --git a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h index 02273938bf..fe4f5ac0e0 100644 --- a/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h +++ b/exporters/otlp/include/opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h @@ -3,6 +3,10 @@ #pragma once +#include +#include +#include + #include "opentelemetry/common/attribute_value.h" #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/sdk/common/attribute_utils.h" @@ -55,23 +59,61 @@ class OtlpPopulateAttributeUtils const opentelemetry::sdk::instrumentationscope::InstrumentationScope &instrumentation_scope) noexcept; - static void PopulateAnyValue(opentelemetry::proto::common::v1::AnyValue *proto_value, - const opentelemetry::common::AttributeValue &value, - bool allow_bytes) noexcept; + /** + * Populate a proto AnyValue from a non-owning AttributeValue. + * When `max_length` is less than `std::numeric_limits::max()`, + * string alternatives are truncated to at most `max_length` bytes using + * UTF-8-safe truncation (Utf8SafePrefixLength) so the resulting proto + * `string_value` stays valid UTF-8 when the input was. Raw bytes + * (`span` when `allow_bytes` is true) are cut at the raw + * byte boundary since they are not UTF-8 text. Non-string alternatives + * are unaffected. + */ + static void PopulateAnyValue( + opentelemetry::proto::common::v1::AnyValue *proto_value, + const opentelemetry::common::AttributeValue &value, + bool allow_bytes, + std::size_t max_length = (std::numeric_limits::max)()) noexcept; + + static void PopulateAnyValue( + opentelemetry::proto::common::v1::AnyValue *proto_value, + const opentelemetry::sdk::common::OwnedAttributeValue &value, + bool allow_bytes, + std::size_t max_length = (std::numeric_limits::max)()) noexcept; + + static void PopulateAttribute( + opentelemetry::proto::common::v1::KeyValue *attribute, + nostd::string_view key, + const opentelemetry::common::AttributeValue &value, + bool allow_bytes, + std::size_t max_length = (std::numeric_limits::max)()) noexcept; - static void PopulateAnyValue(opentelemetry::proto::common::v1::AnyValue *proto_value, - const opentelemetry::sdk::common::OwnedAttributeValue &value, - bool allow_bytes) noexcept; + static void PopulateAttribute( + opentelemetry::proto::common::v1::KeyValue *attribute, + nostd::string_view key, + const opentelemetry::sdk::common::OwnedAttributeValue &value, + bool allow_bytes, + std::size_t max_length = (std::numeric_limits::max)()) noexcept; - static void PopulateAttribute(opentelemetry::proto::common::v1::KeyValue *attribute, - nostd::string_view key, - const opentelemetry::common::AttributeValue &value, - bool allow_bytes) noexcept; + /** + * Byte length of the longest prefix of `value` that fits within `max_bytes` + * without splitting a well-formed UTF-8 multi-byte sequence. A lead byte's + * declared length is only honored when its continuation bytes are present + * and in range (0x80-0xBF); otherwise the lead is treated as a one-byte + * unit, so malformed input degrades to plain byte truncation. The protobuf + * `string` field type requires valid UTF-8, so this utility lets callers + * truncate at a code-point boundary instead of cutting through a multi-byte + * sequence. + */ + static std::size_t Utf8SafePrefixLength(const char *data, + std::size_t size, + std::size_t max_bytes) noexcept; - static void PopulateAttribute(opentelemetry::proto::common::v1::KeyValue *attribute, - nostd::string_view key, - const opentelemetry::sdk::common::OwnedAttributeValue &value, - bool allow_bytes) noexcept; + /// Convenience overload that delegates to the pointer + size variant. + static std::size_t Utf8SafePrefixLength(const std::string &value, std::size_t max_bytes) noexcept + { + return Utf8SafePrefixLength(value.data(), value.size(), max_bytes); + } }; } // namespace otlp diff --git a/exporters/otlp/src/otlp_log_recordable.cc b/exporters/otlp/src/otlp_log_recordable.cc index 4f1877bb41..dc10abfe41 100644 --- a/exporters/otlp/src/otlp_log_recordable.cc +++ b/exporters/otlp/src/otlp_log_recordable.cc @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 #include "opentelemetry/exporters/otlp/otlp_log_recordable.h" +#include #include "opentelemetry/common/attribute_value.h" #include "opentelemetry/common/timestamp.h" #include "opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h" @@ -9,6 +10,7 @@ #include "opentelemetry/nostd/span.h" #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/sdk/instrumentationscope/instrumentation_scope.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/readable_log_record.h" #include "opentelemetry/sdk/resource/resource.h" #include "opentelemetry/trace/span_id.h" @@ -236,7 +238,20 @@ void OtlpLogRecordable::SetTraceFlags(const opentelemetry::trace::TraceFlags &tr void OtlpLogRecordable::SetAttribute(opentelemetry::nostd::string_view key, const opentelemetry::common::AttributeValue &value) noexcept { - OtlpPopulateAttributeUtils::PopulateAttribute(proto_record_.add_attributes(), key, value, true); + if (static_cast(proto_record_.attributes_size()) >= limits_.attribute_count_limit) + { + proto_record_.set_dropped_attributes_count(proto_record_.dropped_attributes_count() + 1); + return; + } + + OtlpPopulateAttributeUtils::PopulateAttribute(proto_record_.add_attributes(), key, value, true, + limits_.attribute_value_length_limit); +} + +void OtlpLogRecordable::SetLogRecordLimits( + const opentelemetry::sdk::logs::LogRecordLimits &limits) noexcept +{ + limits_ = limits; } void OtlpLogRecordable::SetResource(const opentelemetry::sdk::resource::Resource &resource) noexcept diff --git a/exporters/otlp/src/otlp_populate_attribute_utils.cc b/exporters/otlp/src/otlp_populate_attribute_utils.cc index 14259773db..943121e570 100644 --- a/exporters/otlp/src/otlp_populate_attribute_utils.cc +++ b/exporters/otlp/src/otlp_populate_attribute_utils.cc @@ -6,7 +6,8 @@ #endif #include -#include +#include +#include #include #include #include @@ -62,7 +63,8 @@ inline void SetUint64Value(opentelemetry::proto::common::v1::AnyValue *proto_val void OtlpPopulateAttributeUtils::PopulateAnyValue( opentelemetry::proto::common::v1::AnyValue *proto_value, const opentelemetry::common::AttributeValue &value, - bool allow_bytes) noexcept + bool allow_bytes, + std::size_t max_length) noexcept { if (nullptr == proto_value) { @@ -101,48 +103,54 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( } else if (nostd::holds_alternative(value)) { - const char *str_value = nostd::get(value); + const char *str_value = nostd::get(value); + const std::size_t str_len = std::strlen(str_value); + const std::size_t kept_len = Utf8SafePrefixLength(str_value, str_len, max_length); #if defined(ENABLE_OTLP_UTF8_VALIDITY) - if (utf8_range::IsStructurallyValid(str_value)) + // Validity is decided by the original input, so truncation that happens + // to remove invalid bytes does not flip the proto field type. + if (utf8_range::IsStructurallyValid({str_value, str_len})) { - proto_value->set_string_value(str_value); + proto_value->set_string_value(str_value, kept_len); } else { - proto_value->set_bytes_value(str_value, strlen(str_value)); + proto_value->set_bytes_value(str_value, kept_len); } #else - proto_value->set_string_value(str_value); + proto_value->set_string_value(str_value, kept_len); #endif } else if (nostd::holds_alternative(value)) { nostd::string_view str_value = nostd::get(value); + const std::size_t kept_len = + Utf8SafePrefixLength(str_value.data(), str_value.size(), max_length); #if defined(ENABLE_OTLP_UTF8_VALIDITY) if (utf8_range::IsStructurallyValid({str_value.data(), str_value.size()})) { - proto_value->set_string_value(str_value.data(), str_value.size()); + proto_value->set_string_value(str_value.data(), kept_len); } else { - proto_value->set_bytes_value(str_value.data(), str_value.size()); + proto_value->set_bytes_value(str_value.data(), kept_len); } #else - proto_value->set_string_value(str_value.data(), str_value.size()); + proto_value->set_string_value(str_value.data(), kept_len); #endif } else if (nostd::holds_alternative>(value)) { + const auto &bytes = nostd::get>(value); if (allow_bytes) { - proto_value->set_bytes_value( - reinterpret_cast(nostd::get>(value).data()), - nostd::get>(value).size()); + const std::size_t kept_len = (std::min)(bytes.size(), max_length); + proto_value->set_bytes_value(reinterpret_cast(bytes.data()), kept_len); } else { auto array_value = proto_value->mutable_array_value(); - for (const auto &val : nostd::get>(value)) + for (const auto &val : bytes) { array_value->add_values()->set_int_value(val); } @@ -201,17 +209,18 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( auto array_value = proto_value->mutable_array_value(); for (const auto &val : nostd::get>(value)) { + const std::size_t kept_len = Utf8SafePrefixLength(val.data(), val.size(), max_length); #if defined(ENABLE_OTLP_UTF8_VALIDITY) if (utf8_range::IsStructurallyValid({val.data(), val.size()})) { - array_value->add_values()->set_string_value(val.data(), val.size()); + array_value->add_values()->set_string_value(val.data(), kept_len); } else { - array_value->add_values()->set_bytes_value(val.data(), val.size()); + array_value->add_values()->set_bytes_value(val.data(), kept_len); } #else - array_value->add_values()->set_string_value(val.data(), val.size()); + array_value->add_values()->set_string_value(val.data(), kept_len); #endif } } @@ -220,7 +229,8 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( void OtlpPopulateAttributeUtils::PopulateAnyValue( opentelemetry::proto::common::v1::AnyValue *proto_value, const opentelemetry::sdk::common::OwnedAttributeValue &value, - bool allow_bytes) noexcept + bool allow_bytes, + std::size_t max_length) noexcept { if (nullptr == proto_value) { @@ -262,8 +272,8 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( if (allow_bytes) { const std::vector &byte_array = nostd::get>(value); - proto_value->set_bytes_value(reinterpret_cast(byte_array.data()), - byte_array.size()); + const std::size_t kept_len = (std::min)(byte_array.size(), max_length); + proto_value->set_bytes_value(reinterpret_cast(byte_array.data()), kept_len); } else { @@ -277,17 +287,18 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( else if (nostd::holds_alternative(value)) { const std::string &str_value = nostd::get(value); + const std::size_t kept_len = Utf8SafePrefixLength(str_value, max_length); #if defined(ENABLE_OTLP_UTF8_VALIDITY) if (utf8_range::IsStructurallyValid(str_value)) { - proto_value->set_string_value(str_value); + proto_value->set_string_value(str_value.data(), kept_len); } else { - proto_value->set_bytes_value(str_value); + proto_value->set_bytes_value(str_value.data(), kept_len); } #else - proto_value->set_string_value(str_value); + proto_value->set_string_value(str_value.data(), kept_len); #endif } else if (nostd::holds_alternative>(value)) @@ -344,17 +355,18 @@ void OtlpPopulateAttributeUtils::PopulateAnyValue( auto array_value = proto_value->mutable_array_value(); for (const auto &val : nostd::get>(value)) { + const std::size_t kept_len = Utf8SafePrefixLength(val, max_length); #if defined(ENABLE_OTLP_UTF8_VALIDITY) if (utf8_range::IsStructurallyValid(val)) { - array_value->add_values()->set_string_value(val); + array_value->add_values()->set_string_value(val.data(), kept_len); } else { - array_value->add_values()->set_bytes_value(val); + array_value->add_values()->set_bytes_value(val.data(), kept_len); } #else - array_value->add_values()->set_string_value(val); + array_value->add_values()->set_string_value(val.data(), kept_len); #endif } } @@ -364,7 +376,8 @@ void OtlpPopulateAttributeUtils::PopulateAttribute( opentelemetry::proto::common::v1::KeyValue *attribute, nostd::string_view key, const opentelemetry::common::AttributeValue &value, - bool allow_bytes) noexcept + bool allow_bytes, + std::size_t max_length) noexcept { if (nullptr == attribute) { @@ -378,7 +391,7 @@ void OtlpPopulateAttributeUtils::PopulateAttribute( "AttributeValue contains unknown type"); attribute->set_key(key.data(), key.size()); - PopulateAnyValue(attribute->mutable_value(), value, allow_bytes); + PopulateAnyValue(attribute->mutable_value(), value, allow_bytes, max_length); } /** Maps from C++ attribute into OTLP proto attribute. */ @@ -386,7 +399,8 @@ void OtlpPopulateAttributeUtils::PopulateAttribute( opentelemetry::proto::common::v1::KeyValue *attribute, nostd::string_view key, const opentelemetry::sdk::common::OwnedAttributeValue &value, - bool allow_bytes) noexcept + bool allow_bytes, + std::size_t max_length) noexcept { if (nullptr == attribute) { @@ -400,7 +414,7 @@ void OtlpPopulateAttributeUtils::PopulateAttribute( "OwnedAttributeValue contains unknown type"); attribute->set_key(key.data(), key.size()); - PopulateAnyValue(attribute->mutable_value(), value, allow_bytes); + PopulateAnyValue(attribute->mutable_value(), value, allow_bytes, max_length); } void OtlpPopulateAttributeUtils::PopulateAttribute( @@ -431,6 +445,16 @@ void OtlpPopulateAttributeUtils::PopulateAttribute( } } +// The UTF-8-safe prefix algorithm now lives in the SDK common layer so the +// in-memory AttributeConverter can share it. This member is kept as a thin +// forwarder for backward compatibility with existing callers and tests. +std::size_t OtlpPopulateAttributeUtils::Utf8SafePrefixLength(const char *data, + std::size_t size, + std::size_t max_bytes) noexcept +{ + return opentelemetry::sdk::common::Utf8SafePrefixLength(data, size, max_bytes); +} + } // namespace otlp } // namespace exporter OPENTELEMETRY_END_NAMESPACE diff --git a/exporters/otlp/test/otlp_log_recordable_test.cc b/exporters/otlp/test/otlp_log_recordable_test.cc index b722c6c055..d471c86414 100644 --- a/exporters/otlp/test/otlp_log_recordable_test.cc +++ b/exporters/otlp/test/otlp_log_recordable_test.cc @@ -19,6 +19,7 @@ #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/nostd/variant.h" #include "opentelemetry/sdk/instrumentationscope/instrumentation_scope.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/readable_log_record.h" #include "opentelemetry/sdk/logs/recordable.h" #include "opentelemetry/sdk/resource/resource.h" @@ -419,6 +420,135 @@ TEST(OtlpLogRecordable, PopulateRequestSameScope) EXPECT_EQ(req.resource_logs(0).scope_logs(0).log_records_size(), 2); EXPECT_EQ(req.resource_logs(0).scope_logs(0).scope().name(), "lib"); } + +TEST(OtlpLogRecordable, AttributeCountLimitReportsDroppedCount) +{ + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_count_limit = 2; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + rec.SetAttribute("a", static_cast(1)); + rec.SetAttribute("b", static_cast(2)); + rec.SetAttribute("c", static_cast(3)); + rec.SetAttribute("d", static_cast(4)); + + EXPECT_EQ(rec.log_record().attributes_size(), 2); + EXPECT_EQ(rec.log_record().dropped_attributes_count(), 2u); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitTruncatesString) +{ + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 4; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + rec.SetAttribute("k", nostd::string_view("abcdefghij")); + + ASSERT_EQ(rec.log_record().attributes_size(), 1); + EXPECT_EQ(rec.log_record().attributes(0).value().string_value(), "abcd"); + EXPECT_EQ(rec.log_record().dropped_attributes_count(), 0u); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitTruncatesArrayElements) +{ + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 3; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + nostd::string_view values[] = {nostd::string_view("aaaaaa"), nostd::string_view("bb"), + nostd::string_view("cccc")}; + rec.SetAttribute("k", nostd::span(values, 3)); + + ASSERT_EQ(rec.log_record().attributes_size(), 1); + const auto &array = rec.log_record().attributes(0).value().array_value(); + ASSERT_EQ(array.values_size(), 3); + EXPECT_EQ(array.values(0).string_value(), "aaa"); + EXPECT_EQ(array.values(1).string_value(), "bb"); + EXPECT_EQ(array.values(2).string_value(), "ccc"); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitLeavesNonStringTypesUnchanged) +{ + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 1; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + rec.SetAttribute("i", static_cast(1234567890)); + rec.SetAttribute("d", 3.14); + rec.SetAttribute("b", true); + + ASSERT_EQ(rec.log_record().attributes_size(), 3); + EXPECT_EQ(rec.log_record().attributes(0).value().int_value(), 1234567890); + EXPECT_DOUBLE_EQ(rec.log_record().attributes(1).value().double_value(), 3.14); + EXPECT_EQ(rec.log_record().attributes(2).value().bool_value(), true); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitPreservesUtf8MultiByte) +{ + // "h\xC3\xA9llo" is "héllo" in UTF-8; 'é' occupies bytes 1-2. With a 2-byte + // budget the truncator keeps 'h' alone and drops the partial 'é', so the + // resulting protobuf string_value stays valid UTF-8. + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 2; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + rec.SetAttribute("k", nostd::string_view("h\xC3\xA9llo")); + + ASSERT_EQ(rec.log_record().attributes_size(), 1); + EXPECT_EQ(rec.log_record().attributes(0).value().string_value(), "h"); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitKeepsCompletedUtf8Sequence) +{ + // Same input as above, with a 3-byte budget that exactly fits 'h' + 'é'. + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 3; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + rec.SetAttribute("k", nostd::string_view("h\xC3\xA9llo")); + + ASSERT_EQ(rec.log_record().attributes_size(), 1); + EXPECT_EQ(rec.log_record().attributes(0).value().string_value(), "h\xC3\xA9"); +} + +TEST(OtlpLogRecordable, AttributeValueLengthLimitTruncatesBytesAttribute) +{ + opentelemetry::sdk::logs::LogRecordLimits limits; + limits.attribute_value_length_limit = 3; + + OtlpLogRecordable rec; + rec.SetLogRecordLimits(limits); + const uint8_t bytes_in[] = {0x01, 0x02, 0x03, 0x04, 0x05}; + rec.SetAttribute("k", nostd::span(bytes_in, 5)); + + ASSERT_EQ(rec.log_record().attributes_size(), 1); + const auto &b = rec.log_record().attributes(0).value().bytes_value(); + ASSERT_EQ(b.size(), 3u); + EXPECT_EQ(static_cast(b[0]), 0x01); + EXPECT_EQ(static_cast(b[1]), 0x02); + EXPECT_EQ(static_cast(b[2]), 0x03); +} + +TEST(OtlpLogRecordable, DefaultRecordAppliesNoLimitUntilConfigured) +{ + // No SetLogRecordLimits() call: a bare recordable applies no cap. The + // spec-default count limit is injected by the LoggerProvider wiring, not by + // the recordable itself, so every attribute is kept. + OtlpLogRecordable rec; + for (int i = 0; i < 200; ++i) + { + rec.SetAttribute("attr_" + std::to_string(i), static_cast(i)); + } + EXPECT_EQ(rec.log_record().attributes_size(), 200); + EXPECT_EQ(rec.log_record().dropped_attributes_count(), 0u); +} + } // namespace otlp } // namespace exporter OPENTELEMETRY_END_NAMESPACE diff --git a/exporters/otlp/test/otlp_populate_attribute_utils_test.cc b/exporters/otlp/test/otlp_populate_attribute_utils_test.cc new file mode 100644 index 0000000000..ed1f5f5adb --- /dev/null +++ b/exporters/otlp/test/otlp_populate_attribute_utils_test.cc @@ -0,0 +1,76 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include "opentelemetry/exporters/otlp/otlp_populate_attribute_utils.h" +#include "opentelemetry/version.h" + +OPENTELEMETRY_BEGIN_NAMESPACE +namespace exporter +{ +namespace otlp +{ + +// --------------------------------------------------------------------------- +// Utf8SafePrefixLength +// --------------------------------------------------------------------------- +// +// "h\xC3\xA9llo" is "héllo" in UTF-8 (6 bytes). 'é' occupies bytes 1-2 as a +// 2-byte sequence (0xC3 0xA9). These tests pin the behavior at the boundary. + +TEST(Utf8SafePrefixLength, ReturnsFullSizeWhenValueShorterThanMax) +{ + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("abc", 10), 3u); +} + +TEST(Utf8SafePrefixLength, ReturnsZeroForZeroBudget) +{ + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("anything", 0), 0u); +} + +TEST(Utf8SafePrefixLength, AsciiBoundaryEqualsRawByteCount) +{ + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("0123456789", 5), 5u); +} + +TEST(Utf8SafePrefixLength, DropsPartialMultiByteSequenceAtBoundary) +{ + // limit=2: 'h' fits at byte 0; the next codepoint 'é' would span bytes 1-2, + // ending past the limit, so the algorithm stops at byte 1. + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("h\xC3\xA9llo", 2), 1u); +} + +TEST(Utf8SafePrefixLength, KeepsCompletedMultiByteSequenceWithinBudget) +{ + // limit=3: 'h' (1) + 'é' (2) = 3 bytes exactly, still valid UTF-8. + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("h\xC3\xA9llo", 3), 3u); +} + +TEST(Utf8SafePrefixLength, MalformedContinuationFallsBackToByteCount) +{ + // 0xC3 lead byte announces a 2-byte sequence, but 0x28 ('(') is not a valid + // continuation byte (not in 0x80-0xBF). The lead is treated as a one-byte + // unit, then '(' is its own one-byte unit, yielding a 2-byte prefix. + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("\xC3(abc", 2), 2u); +} + +TEST(Utf8SafePrefixLength, InvalidLeadByteCountedAsOneByte) +{ + // 0xFF is not a valid UTF-8 lead byte (>= 0xF8). The algorithm treats it as + // a one-byte unit, identical to raw byte truncation in this case. + std::string value("\xFF\xFF\xFF\xFF\xFF", 5); + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength(value, 3), 3u); +} + +TEST(Utf8SafePrefixLength, TruncatedTailLeadByteFallsBack) +{ + // String ends mid-sequence (lone 0xC3 at the tail); the algorithm should + // treat it as one byte rather than reading past the buffer. + EXPECT_EQ(OtlpPopulateAttributeUtils::Utf8SafePrefixLength("ab\xC3", 10), 3u); +} + +} // namespace otlp +} // namespace exporter +OPENTELEMETRY_END_NAMESPACE diff --git a/sdk/include/opentelemetry/sdk/common/attribute_utils.h b/sdk/include/opentelemetry/sdk/common/attribute_utils.h index 3010c6b9bd..c2e75a0f97 100644 --- a/sdk/include/opentelemetry/sdk/common/attribute_utils.h +++ b/sdk/include/opentelemetry/sdk/common/attribute_utils.h @@ -3,8 +3,11 @@ #pragma once +#include +#include #include #include +#include #include #include #include @@ -68,11 +71,94 @@ enum OwnedAttributeType : std::uint8_t kTypeSpanByte }; +/** + * Byte length of the longest prefix of `data[0, size)` that fits within + * `max_bytes` without splitting a well-formed UTF-8 multi-byte sequence. A lead + * byte's declared length is only honored when its continuation bytes are + * present and in range (0x80-0xBF); otherwise the lead is treated as a one-byte + * unit, so malformed input degrades to plain byte truncation. Callers that need + * the result to stay valid UTF-8 (string attributes, whose protobuf/JSON wire + * form requires it) truncate at this boundary instead of cutting through a + * multi-byte sequence. + */ +// UTF-8 lead-byte ranges used below: +// 0x00-0x7F 0xxxxxxx ASCII, 1 byte +// 0x80-0xBF 10xxxxxx continuation byte (never a valid lead) +// 0xC0-0xDF 110xxxxx lead of a 2-byte sequence +// 0xE0-0xEF 1110xxxx lead of a 3-byte sequence +// 0xF0-0xF7 11110xxx lead of a 4-byte sequence +// 0xF8-0xFF not a valid lead byte +inline std::size_t Utf8SafePrefixLength(const char *data, + std::size_t size, + std::size_t max_bytes) noexcept +{ + // When the whole value fits within the budget (including the common + // unbounded case where max_bytes is SIZE_MAX), no truncation is possible, + // so skip the per-byte scan. + if (max_bytes >= size) + { + return size; + } + std::size_t i = 0; + while (i < size) + { + const auto lead = static_cast(data[i]); + std::size_t seq = (lead < 0x80) ? 1 + : (lead < 0xC0) ? 1 + : (lead < 0xE0) ? 2 + : (lead < 0xF0) ? 3 + : (lead < 0xF8) ? 4 + : 1; + if (seq > 1) + { + if (i + seq > size) + { + seq = 1; + } + else + { + for (std::size_t k = 1; k < seq; ++k) + { + const auto continuation = static_cast(data[i + k]); + if (continuation < 0x80 || continuation > 0xBF) + { + seq = 1; + break; + } + } + } + } + if (i + seq > max_bytes) + { + break; + } + i += seq; + } + return i; +} + /** * Creates an owned copy (OwnedAttributeValue) of a non-owning AttributeValue. + * + * The default-constructed converter does not truncate. To apply a byte-length + * cap during conversion, construct with the `(std::size_t max_length)` + * overload. Only the string, string-array, and bytes alternatives are capped; + * other alternatives are unaffected. String alternatives (std::string, + * string_view, const char*, and arrays of them) are truncated at a UTF-8 + * code-point boundary via Utf8SafePrefixLength so the kept value stays valid + * UTF-8 when the input was, matching the OTel convention that std::string holds + * UTF-8 text. The bytes alternative (span) carries raw binary + * data and is cut at the exact byte boundary with no encoding semantics. */ struct AttributeConverter { + AttributeConverter() = default; + + /// Constructs a converter that truncates string and bytes alternatives to + /// at most `max_length` bytes during conversion. Other alternatives are + /// unaffected. Used by recordables enforcing attribute_value_length_limit. + explicit AttributeConverter(std::size_t max_length) noexcept : max_length_(max_length) {} + OwnedAttributeValue operator()(bool v) { return OwnedAttributeValue(v); } OwnedAttributeValue operator()(int32_t v) { return OwnedAttributeValue(v); } OwnedAttributeValue operator()(uint32_t v) { return OwnedAttributeValue(v); } @@ -81,11 +167,31 @@ struct AttributeConverter OwnedAttributeValue operator()(double v) { return OwnedAttributeValue(v); } OwnedAttributeValue operator()(nostd::string_view v) { - return OwnedAttributeValue(std::string(v)); + const std::size_t kept = + v.size() > max_length_ ? Utf8SafePrefixLength(v.data(), v.size(), max_length_) : v.size(); + return OwnedAttributeValue(std::string(v.data(), kept)); + } + OwnedAttributeValue operator()(std::string v) + { + if (v.size() > max_length_) + { + v.resize(Utf8SafePrefixLength(v.data(), v.size(), max_length_)); + } + return OwnedAttributeValue(std::move(v)); + } + OwnedAttributeValue operator()(const char *v) + { + // Delegate to the string_view overload so an oversized C string is not + // copied in full before being truncated, and so it shares the UTF-8-safe + // boundary handling. + return (*this)(nostd::string_view(v)); + } + OwnedAttributeValue operator()(nostd::span v) + { + // Raw bytes carry no encoding, so cut at the exact byte boundary. + const std::size_t kept = (std::min)(v.size(), max_length_); + return OwnedAttributeValue(std::vector(v.data(), v.data() + kept)); } - OwnedAttributeValue operator()(std::string v) { return OwnedAttributeValue(std::move(v)); } - OwnedAttributeValue operator()(const char *v) { return OwnedAttributeValue(std::string(v)); } - OwnedAttributeValue operator()(nostd::span v) { return convertSpan(v); } OwnedAttributeValue operator()(nostd::span v) { return convertSpan(v); } OwnedAttributeValue operator()(nostd::span v) { return convertSpan(v); } OwnedAttributeValue operator()(nostd::span v) { return convertSpan(v); } @@ -94,7 +200,16 @@ struct AttributeConverter OwnedAttributeValue operator()(nostd::span v) { return convertSpan(v); } OwnedAttributeValue operator()(nostd::span v) { - return convertSpan(v); + std::vector result; + result.reserve(v.size()); + for (const auto &sv : v) + { + const std::size_t kept = sv.size() > max_length_ + ? Utf8SafePrefixLength(sv.data(), sv.size(), max_length_) + : sv.size(); + result.emplace_back(sv.data(), kept); + } + return OwnedAttributeValue(std::move(result)); } template @@ -102,6 +217,9 @@ struct AttributeConverter { return OwnedAttributeValue(std::vector(vals.begin(), vals.end())); } + +private: + std::size_t max_length_ = (std::numeric_limits::max)(); }; /** diff --git a/sdk/include/opentelemetry/sdk/logs/batch_log_record_processor.h b/sdk/include/opentelemetry/sdk/logs/batch_log_record_processor.h index 1ec8504bb7..4ce2370798 100644 --- a/sdk/include/opentelemetry/sdk/logs/batch_log_record_processor.h +++ b/sdk/include/opentelemetry/sdk/logs/batch_log_record_processor.h @@ -109,6 +109,11 @@ class BatchLogRecordProcessor : public LogRecordProcessor bool HasEnabledFilter() const noexcept override { return false; } + bool RecordableEnforcesLogRecordLimits() const noexcept override + { + return exporter_ != nullptr && exporter_->RecordableEnforcesLogRecordLimits(); + } + /** * Class destructor which invokes the Shutdown() method. */ diff --git a/sdk/include/opentelemetry/sdk/logs/exporter.h b/sdk/include/opentelemetry/sdk/logs/exporter.h index 42d90b3305..1fae5cb85c 100644 --- a/sdk/include/opentelemetry/sdk/logs/exporter.h +++ b/sdk/include/opentelemetry/sdk/logs/exporter.h @@ -68,6 +68,15 @@ class OPENTELEMETRY_EXPORT LogRecordExporter */ virtual bool Shutdown( std::chrono::microseconds timeout = (std::chrono::microseconds::max)()) noexcept = 0; + + /** + * Returns true when the recordable produced by MakeRecordable() enforces + * LogRecord attribute limits (count and value length). The default returns + * false so the SDK can skip the per-record SetLogRecordLimits() call for + * recordables that ignore limits. Exporters whose recordable applies the + * limits (OTLP, ostream) override this to return true. + */ + virtual bool RecordableEnforcesLogRecordLimits() const noexcept { return false; } }; } // namespace logs } // namespace sdk diff --git a/sdk/include/opentelemetry/sdk/logs/log_record_limits.h b/sdk/include/opentelemetry/sdk/logs/log_record_limits.h new file mode 100644 index 0000000000..0d16bf3ccd --- /dev/null +++ b/sdk/include/opentelemetry/sdk/logs/log_record_limits.h @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include "opentelemetry/version.h" + +OPENTELEMETRY_BEGIN_NAMESPACE +namespace sdk +{ +namespace logs +{ + +/** + * LogRecordLimits carries the SDK-level attribute limits applied to every + * LogRecord emitted through a LoggerProvider. The defaults match the + * specification: at most 128 attributes per record, attribute value length + * unlimited (no truncation). + * + * See https://opentelemetry.io/docs/specs/otel/logs/sdk/#logrecord-limits. + */ +struct LogRecordLimits +{ + static constexpr std::size_t kDefaultAttributeCountLimit = 128; + static constexpr std::size_t kDefaultAttributeValueLengthLimit = + (std::numeric_limits::max)(); + + std::size_t attribute_count_limit = kDefaultAttributeCountLimit; + std::size_t attribute_value_length_limit = kDefaultAttributeValueLengthLimit; + + /** + * Limits that impose no cap. A recordable uses these until a LoggerProvider + * injects the configured limits, so a recordable created outside a provider + * does not enforce any limit on its own. + */ + static constexpr LogRecordLimits NoLimits() noexcept + { + return LogRecordLimits{(std::numeric_limits::max)(), + (std::numeric_limits::max)()}; + } +}; + +} // namespace logs +} // namespace sdk +OPENTELEMETRY_END_NAMESPACE diff --git a/sdk/include/opentelemetry/sdk/logs/logger_context.h b/sdk/include/opentelemetry/sdk/logs/logger_context.h index 038da2d948..65d740f722 100644 --- a/sdk/include/opentelemetry/sdk/logs/logger_context.h +++ b/sdk/include/opentelemetry/sdk/logs/logger_context.h @@ -9,6 +9,7 @@ #include "logger_config.h" #include "opentelemetry/sdk/instrumentationscope/scope_configurator.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/processor.h" #include "opentelemetry/sdk/resource/resource.h" #include "opentelemetry/version.h" @@ -43,7 +44,8 @@ class LoggerContext std::make_unique>( instrumentationscope::ScopeConfigurator::Builder( LoggerConfig::Default()) - .Build())) noexcept; + .Build()), + LogRecordLimits log_record_limits = LogRecordLimits()) noexcept; /** * Attaches a log processor to list of configured processors to this logger context. @@ -76,6 +78,21 @@ class LoggerContext const instrumentationscope::ScopeConfigurator &GetLoggerConfigurator() const noexcept; + /** + * Obtain the LogRecord limits applied by this context. + * @return The LogRecordLimits for this logger context. + */ + const LogRecordLimits &GetLogRecordLimits() const noexcept; + + /** + * Whether any configured processor produces a recordable that enforces the + * LogRecord attribute limits. Computed once at construction (and refreshed by + * AddProcessor) so the Logger hot path can skip the per-record + * SetLogRecordLimits() virtual call when no processor needs it. + * @return true if at least one processor enforces limits. + */ + bool RecordableEnforcesLogRecordLimits() const noexcept; + /** * Force all active LogProcessors to flush any buffered logs * within the given timeout. @@ -93,6 +110,13 @@ class LoggerContext std::unique_ptr processor_; std::unique_ptr> logger_configurator_; + + LogRecordLimits log_record_limits_; + + // Cached capability flag: true when at least one configured processor's + // recordable enforces the LogRecord attribute limits. Refreshed by + // AddProcessor so the Logger hot path reads a plain bool. + bool recordable_enforces_limits_{false}; }; } // namespace logs } // namespace sdk diff --git a/sdk/include/opentelemetry/sdk/logs/logger_provider_factory.h b/sdk/include/opentelemetry/sdk/logs/logger_provider_factory.h index 6745b39cee..6edc2c2983 100644 --- a/sdk/include/opentelemetry/sdk/logs/logger_provider_factory.h +++ b/sdk/include/opentelemetry/sdk/logs/logger_provider_factory.h @@ -6,6 +6,7 @@ #include #include +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/logger_context.h" #include "opentelemetry/sdk/logs/logger_provider.h" #include "opentelemetry/sdk/logs/processor.h" @@ -66,6 +67,15 @@ class OPENTELEMETRY_EXPORT LoggerProviderFactory const opentelemetry::sdk::resource::Resource &resource, std::unique_ptr> logger_configurator); + /** + * Create a LoggerProvider with explicit LogRecord limits. + */ + static std::unique_ptr Create( + std::vector> &&processors, + const opentelemetry::sdk::resource::Resource &resource, + std::unique_ptr> logger_configurator, + LogRecordLimits log_record_limits); + /** * Create a LoggerProvider. */ diff --git a/sdk/include/opentelemetry/sdk/logs/multi_log_record_processor.h b/sdk/include/opentelemetry/sdk/logs/multi_log_record_processor.h index 8158a2dbf6..b7f73ec0c1 100644 --- a/sdk/include/opentelemetry/sdk/logs/multi_log_record_processor.h +++ b/sdk/include/opentelemetry/sdk/logs/multi_log_record_processor.h @@ -64,6 +64,8 @@ class MultiLogRecordProcessor : public LogRecordProcessor bool HasEnabledFilter() const noexcept override; + bool RecordableEnforcesLogRecordLimits() const noexcept override; + protected: /** * Exports all log records that have not yet been exported to the configured Exporter. diff --git a/sdk/include/opentelemetry/sdk/logs/multi_recordable.h b/sdk/include/opentelemetry/sdk/logs/multi_recordable.h index fe7a8b571f..d1c2fe5451 100644 --- a/sdk/include/opentelemetry/sdk/logs/multi_recordable.h +++ b/sdk/include/opentelemetry/sdk/logs/multi_recordable.h @@ -13,6 +13,7 @@ #include "opentelemetry/common/timestamp.h" #include "opentelemetry/logs/log_record.h" #include "opentelemetry/nostd/string_view.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/processor.h" #include "opentelemetry/sdk/logs/recordable.h" #include "opentelemetry/sdk/resource/resource.h" @@ -107,6 +108,12 @@ class MultiRecordable final : public Recordable void SetInstrumentationScope(const opentelemetry::sdk::instrumentationscope::InstrumentationScope &instrumentation_scope) noexcept override; + /** + * Propagate attribute limits to every wrapped recordable. Must be called + * before any SetAttribute call. + */ + void SetLogRecordLimits(const LogRecordLimits &limits) noexcept override; + private: std::unordered_map> recordables_; }; diff --git a/sdk/include/opentelemetry/sdk/logs/processor.h b/sdk/include/opentelemetry/sdk/logs/processor.h index 557db92250..41e47d1a27 100644 --- a/sdk/include/opentelemetry/sdk/logs/processor.h +++ b/sdk/include/opentelemetry/sdk/logs/processor.h @@ -111,6 +111,16 @@ class LogRecordProcessor { return true; } + +public: + /** + * Returns true when records produced through this processor enforce LogRecord + * attribute limits (count and value length). The default returns false. + * Processors backed by an exporter delegate to the exporter; composite + * processors return true if any child does. The SDK Logger consults this once + * per context to decide whether to push limits onto each recordable. + */ + virtual bool RecordableEnforcesLogRecordLimits() const noexcept { return false; } }; } // namespace logs } // namespace sdk diff --git a/sdk/include/opentelemetry/sdk/logs/read_write_log_record.h b/sdk/include/opentelemetry/sdk/logs/read_write_log_record.h index 044285540f..ad0c6086c0 100644 --- a/sdk/include/opentelemetry/sdk/logs/read_write_log_record.h +++ b/sdk/include/opentelemetry/sdk/logs/read_write_log_record.h @@ -14,6 +14,7 @@ #include "opentelemetry/logs/severity.h" #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/sdk/common/attribute_utils.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/readable_log_record.h" #include "opentelemetry/trace/span_id.h" #include "opentelemetry/trace/trace_flags.h" @@ -153,6 +154,13 @@ class ReadWriteLogRecord final : public ReadableLogRecord void SetAttribute(nostd::string_view key, const opentelemetry::common::AttributeValue &value) noexcept override; + /** + * Apply attribute count and value length limits. Must be called before any + * SetAttribute call to take effect. The limits are copied into this + * recordable. + */ + void SetLogRecordLimits(const LogRecordLimits &limits) noexcept override; + /** * Get attributes of this log. * @return the body field of this log @@ -160,6 +168,12 @@ class ReadWriteLogRecord final : public ReadableLogRecord const std::unordered_map & GetAttributes() const noexcept override; + /** + * Get the number of attributes dropped because the attribute count limit + * was reached. Truncated string values are not counted as dropped. + */ + uint32_t GetDroppedAttributesCount() const noexcept override; + /** * Get resource of this log * @return the resource of this log @@ -200,6 +214,13 @@ class ReadWriteLogRecord final : public ReadableLogRecord int64_t event_id_{0}; std::string event_name_; + // Stored by value so the recordable does not depend on the limits object + // outliving the LoggerContext that supplied it. Defaults to no limits; the + // LoggerProvider wiring injects the configured limits via SetLogRecordLimits, + // so a recordable used outside a provider does not cap attributes on its own. + LogRecordLimits limits_ = LogRecordLimits::NoLimits(); + uint32_t dropped_attributes_count_{0}; + // We do not pay for trace state when not necessary struct TraceState { diff --git a/sdk/include/opentelemetry/sdk/logs/readable_log_record.h b/sdk/include/opentelemetry/sdk/logs/readable_log_record.h index 3245717ce2..b612bcc51d 100644 --- a/sdk/include/opentelemetry/sdk/logs/readable_log_record.h +++ b/sdk/include/opentelemetry/sdk/logs/readable_log_record.h @@ -114,6 +114,14 @@ class ReadableLogRecord : public Recordable virtual const std::unordered_map & GetAttributes() const noexcept = 0; + /** + * Get the number of attributes dropped because the attribute count limit + * was reached. The default implementation reports zero so existing + * recordables that do not enforce limits compile without changes. + * @return the number of dropped attributes + */ + virtual uint32_t GetDroppedAttributesCount() const noexcept { return 0; } + /** * Get resource of this log * @return the resource of this log diff --git a/sdk/include/opentelemetry/sdk/logs/recordable.h b/sdk/include/opentelemetry/sdk/logs/recordable.h index ea203fb8a4..d7ddb3b34a 100644 --- a/sdk/include/opentelemetry/sdk/logs/recordable.h +++ b/sdk/include/opentelemetry/sdk/logs/recordable.h @@ -21,6 +21,9 @@ class InstrumentationScope; namespace logs { + +struct LogRecordLimits; + /** * Maintains a representation of a log in a format that can be processed by a recorder. * @@ -43,6 +46,15 @@ class Recordable : public opentelemetry::logs::LogRecord virtual void SetInstrumentationScope( const opentelemetry::sdk::instrumentationscope::InstrumentationScope &instrumentation_scope) noexcept = 0; + + /** + * Apply attribute count and value length limits to this log. The default + * implementation is a no-op; concrete recordables that wish to enforce + * limits override this and copy the supplied limits before any + * SetAttribute call is observed, so the caller does not need to keep the + * supplied object alive. + */ + virtual void SetLogRecordLimits(const LogRecordLimits & /* limits */) noexcept {} }; } // namespace logs diff --git a/sdk/include/opentelemetry/sdk/logs/simple_log_record_processor.h b/sdk/include/opentelemetry/sdk/logs/simple_log_record_processor.h index 94b0bb7bdd..42f1b1b7e4 100644 --- a/sdk/include/opentelemetry/sdk/logs/simple_log_record_processor.h +++ b/sdk/include/opentelemetry/sdk/logs/simple_log_record_processor.h @@ -51,6 +51,11 @@ class SimpleLogRecordProcessor : public LogRecordProcessor bool HasEnabledFilter() const noexcept override { return false; } + bool RecordableEnforcesLogRecordLimits() const noexcept override + { + return exporter_ != nullptr && exporter_->RecordableEnforcesLogRecordLimits(); + } + bool IsShutdown() const noexcept; private: diff --git a/sdk/src/configuration/sdk_builder.cc b/sdk/src/configuration/sdk_builder.cc index f60362fa9e..98f5fde13b 100644 --- a/sdk/src/configuration/sdk_builder.cc +++ b/sdk/src/configuration/sdk_builder.cc @@ -64,6 +64,7 @@ #include "opentelemetry/sdk/configuration/jaeger_remote_sampler_configuration.h" #include "opentelemetry/sdk/configuration/log_record_exporter_configuration.h" #include "opentelemetry/sdk/configuration/log_record_exporter_configuration_visitor.h" +#include "opentelemetry/sdk/configuration/log_record_limits_configuration.h" #include "opentelemetry/sdk/configuration/log_record_processor_configuration.h" #include "opentelemetry/sdk/configuration/log_record_processor_configuration_visitor.h" #include "opentelemetry/sdk/configuration/logger_config_configuration.h" @@ -133,6 +134,7 @@ #include "opentelemetry/sdk/logs/batch_log_record_processor_factory.h" #include "opentelemetry/sdk/logs/batch_log_record_processor_options.h" #include "opentelemetry/sdk/logs/exporter.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/logger_config.h" #include "opentelemetry/sdk/logs/logger_provider.h" #include "opentelemetry/sdk/logs/logger_provider_factory.h" @@ -1907,20 +1909,34 @@ std::unique_ptr SdkBuilder::CreateLogg sdk_processors.push_back(CreateLogRecordProcessor(processor_model)); } - // FIXME-SDK: https://github.com/open-telemetry/opentelemetry-cpp/issues/3303 - // FIXME-SDK: use limits + opentelemetry::sdk::logs::LogRecordLimits log_record_limits; + if (model->limits) + { + log_record_limits.attribute_value_length_limit = model->limits->attribute_value_length_limit; + log_record_limits.attribute_count_limit = model->limits->attribute_count_limit; + } + + std::unique_ptr> + logger_configurator; if (model->logger_configurator) { - auto logger_configurator = CreateLoggerConfigurator(model->logger_configurator); - sdk = opentelemetry::sdk::logs::LoggerProviderFactory::Create( - std::move(sdk_processors), resource, std::move(logger_configurator)); + logger_configurator = CreateLoggerConfigurator(model->logger_configurator); } else { - sdk = opentelemetry::sdk::logs::LoggerProviderFactory::Create(std::move(sdk_processors), - resource); + logger_configurator = + std::make_unique>( + opentelemetry::sdk::instrumentationscope:: + ScopeConfigurator::Builder( + opentelemetry::sdk::logs::LoggerConfig::Default()) + .Build()); } + sdk = opentelemetry::sdk::logs::LoggerProviderFactory::Create( + std::move(sdk_processors), resource, std::move(logger_configurator), log_record_limits); + return sdk; } diff --git a/sdk/src/logs/logger.cc b/sdk/src/logs/logger.cc index 5c643da7b6..80cd8eddfd 100644 --- a/sdk/src/logs/logger.cc +++ b/sdk/src/logs/logger.cc @@ -162,6 +162,10 @@ opentelemetry::nostd::unique_ptr Logger::CreateL auto recordable = context_->GetProcessor().MakeRecordable(); + if (context_->RecordableEnforcesLogRecordLimits()) + { + recordable->SetLogRecordLimits(context_->GetLogRecordLimits()); + } recordable->SetObservedTimestamp(std::chrono::system_clock::now()); StampSpanContextFromVariant( @@ -184,6 +188,10 @@ opentelemetry::nostd::unique_ptr Logger::CreateL auto recordable = context_->GetProcessor().MakeRecordable(); + if (context_->RecordableEnforcesLogRecordLimits()) + { + recordable->SetLogRecordLimits(context_->GetLogRecordLimits()); + } recordable->SetObservedTimestamp(std::chrono::system_clock::now()); StampSpanContextFromVariant(context_or_span, *recordable); diff --git a/sdk/src/logs/logger_context.cc b/sdk/src/logs/logger_context.cc index f7ad9db0c6..a1e8defbfa 100644 --- a/sdk/src/logs/logger_context.cc +++ b/sdk/src/logs/logger_context.cc @@ -7,6 +7,7 @@ #include #include "opentelemetry/sdk/instrumentationscope/scope_configurator.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/logger_config.h" #include "opentelemetry/sdk/logs/logger_context.h" #include "opentelemetry/sdk/logs/multi_log_record_processor.h" @@ -20,20 +21,24 @@ namespace sdk namespace logs { -LoggerContext::LoggerContext(std::vector> &&processors, - const opentelemetry::sdk::resource::Resource &resource, - std::unique_ptr> - logger_configurator) noexcept +LoggerContext::LoggerContext( + std::vector> &&processors, + const opentelemetry::sdk::resource::Resource &resource, + std::unique_ptr> logger_configurator, + LogRecordLimits log_record_limits) noexcept : resource_(resource), processor_( std::unique_ptr(new MultiLogRecordProcessor(std::move(processors)))), - logger_configurator_(std::move(logger_configurator)) + logger_configurator_(std::move(logger_configurator)), + log_record_limits_(log_record_limits), + recordable_enforces_limits_(processor_->RecordableEnforcesLogRecordLimits()) {} void LoggerContext::AddProcessor(std::unique_ptr processor) noexcept { auto multi_processor = static_cast(processor_.get()); multi_processor->AddProcessor(std::move(processor)); + recordable_enforces_limits_ = processor_->RecordableEnforcesLogRecordLimits(); } LogRecordProcessor &LoggerContext::GetProcessor() const noexcept @@ -52,6 +57,16 @@ const instrumentationscope::ScopeConfigurator &LoggerContext::GetL return *logger_configurator_; } +const LogRecordLimits &LoggerContext::GetLogRecordLimits() const noexcept +{ + return log_record_limits_; +} + +bool LoggerContext::RecordableEnforcesLogRecordLimits() const noexcept +{ + return recordable_enforces_limits_; +} + bool LoggerContext::ForceFlush(std::chrono::microseconds timeout) noexcept { return processor_->ForceFlush(timeout); diff --git a/sdk/src/logs/logger_provider_factory.cc b/sdk/src/logs/logger_provider_factory.cc index f80dd43b5d..e8056f94d9 100644 --- a/sdk/src/logs/logger_provider_factory.cc +++ b/sdk/src/logs/logger_provider_factory.cc @@ -6,6 +6,7 @@ #include #include "opentelemetry/sdk/instrumentationscope/scope_configurator.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/logger_config.h" #include "opentelemetry/sdk/logs/logger_context.h" #include "opentelemetry/sdk/logs/logger_provider.h" @@ -76,6 +77,19 @@ std::unique_ptr LoggerProviderFactory: return provider; } +std::unique_ptr LoggerProviderFactory::Create( + std::vector> &&processors, + const resource::Resource &resource, + std::unique_ptr> logger_configurator, + LogRecordLimits log_record_limits) +{ + auto context = std::make_unique(std::move(processors), resource, + std::move(logger_configurator), log_record_limits); + std::unique_ptr provider( + new LoggerProvider(std::move(context))); + return provider; +} + std::unique_ptr LoggerProviderFactory::Create( std::unique_ptr context) { diff --git a/sdk/src/logs/multi_log_record_processor.cc b/sdk/src/logs/multi_log_record_processor.cc index 17826bcf43..8ebb004213 100644 --- a/sdk/src/logs/multi_log_record_processor.cc +++ b/sdk/src/logs/multi_log_record_processor.cc @@ -110,6 +110,18 @@ bool MultiLogRecordProcessor::HasEnabledFilter() const noexcept return false; } +bool MultiLogRecordProcessor::RecordableEnforcesLogRecordLimits() const noexcept +{ + for (const auto &processor : processors_) + { + if (processor != nullptr && processor->RecordableEnforcesLogRecordLimits()) + { + return true; + } + } + return false; +} + bool MultiLogRecordProcessor::ForceFlush(std::chrono::microseconds timeout) noexcept { return InternalForceFlush(timeout); diff --git a/sdk/src/logs/multi_recordable.cc b/sdk/src/logs/multi_recordable.cc index 1f4f1b8311..f63ea6ce41 100644 --- a/sdk/src/logs/multi_recordable.cc +++ b/sdk/src/logs/multi_recordable.cc @@ -184,6 +184,17 @@ void MultiRecordable::SetInstrumentationScope( } } +void MultiRecordable::SetLogRecordLimits(const LogRecordLimits &limits) noexcept +{ + for (auto &recordable : recordables_) + { + if (recordable.second) + { + recordable.second->SetLogRecordLimits(limits); + } + } +} + } // namespace logs } // namespace sdk diff --git a/sdk/src/logs/read_write_log_record.cc b/sdk/src/logs/read_write_log_record.cc index 160fa59848..ebf46a0778 100644 --- a/sdk/src/logs/read_write_log_record.cc +++ b/sdk/src/logs/read_write_log_record.cc @@ -6,12 +6,14 @@ #include #include #include +#include #include "opentelemetry/common/attribute_value.h" #include "opentelemetry/common/timestamp.h" #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/nostd/variant.h" #include "opentelemetry/sdk/common/attribute_utils.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" #include "opentelemetry/sdk/logs/read_write_log_record.h" #include "opentelemetry/trace/span_id.h" #include "opentelemetry/trace/trace_flags.h" @@ -155,8 +157,32 @@ void ReadWriteLogRecord::SetAttribute(nostd::string_view key, const opentelemetry::common::AttributeValue &value) noexcept { std::string safe_key(key); - opentelemetry::sdk::common::AttributeConverter converter; - attributes_map_[safe_key] = nostd::visit(converter, value); + + auto it = attributes_map_.find(safe_key); + if (it == attributes_map_.end()) + { + if (attributes_map_.size() >= limits_.attribute_count_limit) + { + ++dropped_attributes_count_; + return; + } + opentelemetry::sdk::common::AttributeConverter converter(limits_.attribute_value_length_limit); + attributes_map_.emplace(std::move(safe_key), nostd::visit(converter, value)); + return; + } + + opentelemetry::sdk::common::AttributeConverter converter(limits_.attribute_value_length_limit); + it->second = nostd::visit(converter, value); +} + +void ReadWriteLogRecord::SetLogRecordLimits(const LogRecordLimits &limits) noexcept +{ + limits_ = limits; +} + +uint32_t ReadWriteLogRecord::GetDroppedAttributesCount() const noexcept +{ + return dropped_attributes_count_; } const std::unordered_map & diff --git a/sdk/test/common/attribute_utils_test.cc b/sdk/test/common/attribute_utils_test.cc index 466179594c..c4b6abe378 100644 --- a/sdk/test/common/attribute_utils_test.cc +++ b/sdk/test/common/attribute_utils_test.cc @@ -13,6 +13,7 @@ #include "opentelemetry/common/attribute_value.h" #include "opentelemetry/common/key_value_iterable_view.h" +#include "opentelemetry/nostd/span.h" #include "opentelemetry/nostd/string_view.h" #include "opentelemetry/nostd/utility.h" #include "opentelemetry/nostd/variant.h" @@ -193,3 +194,89 @@ TEST(AttributeMapTest, EqualTo) EXPECT_FALSE(attribute_map.EqualTo(kv_iterable_different_size)); EXPECT_FALSE(attribute_map.EqualTo(kv_iterable_different_all)); } + +// --------------------------------------------------------------------------- +// AttributeConverter truncation +// --------------------------------------------------------------------------- +// +// "h\xC3\xA9llo" is "héllo" in UTF-8 (6 bytes). 'é' occupies bytes 1-2 as a +// 2-byte sequence (0xC3 0xA9). String alternatives are truncated at a UTF-8 +// code-point boundary; the raw bytes alternative is cut at the exact byte. + +TEST(AttributeConverterTruncation, DefaultConverterDoesNotTruncate) +{ + opentelemetry::sdk::common::AttributeConverter converter; + auto v = converter(opentelemetry::nostd::string_view("0123456789")); + EXPECT_EQ(opentelemetry::nostd::get(v), "0123456789"); +} + +TEST(AttributeConverterTruncation, AsciiTruncatesAtByteCount) +{ + opentelemetry::sdk::common::AttributeConverter converter(5); + auto v = converter(opentelemetry::nostd::string_view("0123456789")); + EXPECT_EQ(opentelemetry::nostd::get(v), "01234"); +} + +TEST(AttributeConverterTruncation, KeepsCompleteMultiByteSequenceWithinBudget) +{ + // 'h' (1 byte) + 'é' (2 bytes) = 3 bytes exactly, still valid UTF-8. + opentelemetry::sdk::common::AttributeConverter converter(3); + auto v = converter(opentelemetry::nostd::string_view("h\xC3\xA9llo")); + EXPECT_EQ(opentelemetry::nostd::get(v), std::string("h\xC3\xA9")); +} + +TEST(AttributeConverterTruncation, DropsPartialMultiByteSequenceAtBoundary) +{ + // limit=2: 'h' fits at byte 0; 'é' would span bytes 1-2, ending past the + // limit, so the kept prefix stops at byte 1. + opentelemetry::sdk::common::AttributeConverter converter(2); + auto v = converter(opentelemetry::nostd::string_view("h\xC3\xA9llo")); + EXPECT_EQ(opentelemetry::nostd::get(v), "h"); +} + +TEST(AttributeConverterTruncation, StdStringIsTruncatedUtf8Safe) +{ + opentelemetry::sdk::common::AttributeConverter converter(2); + auto v = converter(std::string("h\xC3\xA9llo")); + EXPECT_EQ(opentelemetry::nostd::get(v), "h"); +} + +TEST(AttributeConverterTruncation, ConstCharPtrIsTruncatedUtf8Safe) +{ + opentelemetry::sdk::common::AttributeConverter converter(2); + const char *value = "h\xC3\xA9llo"; + auto v = converter(value); + EXPECT_EQ(opentelemetry::nostd::get(v), "h"); +} + +TEST(AttributeConverterTruncation, BytesAreCutAtRawByteBoundary) +{ + // Raw bytes have no encoding, so truncation is a plain byte cut even when the + // leading bytes look like a multi-byte UTF-8 sequence. + const uint8_t bytes[] = {0xC3, 0xA9, 0x01, 0x02}; + opentelemetry::sdk::common::AttributeConverter converter(1); + auto v = converter(opentelemetry::nostd::span(bytes, 4)); + const auto &stored = opentelemetry::nostd::get>(v); + ASSERT_EQ(stored.size(), 1u); + EXPECT_EQ(stored[0], 0xC3); +} + +TEST(AttributeConverterTruncation, StringArrayTruncatedPerElementUtf8Safe) +{ + opentelemetry::nostd::string_view values[] = {opentelemetry::nostd::string_view("h\xC3\xA9llo"), + opentelemetry::nostd::string_view("ab")}; + opentelemetry::sdk::common::AttributeConverter converter(2); + auto v = + converter(opentelemetry::nostd::span(values, 2)); + const auto &stored = opentelemetry::nostd::get>(v); + ASSERT_EQ(stored.size(), 2u); + EXPECT_EQ(stored[0], "h"); + EXPECT_EQ(stored[1], "ab"); +} + +TEST(AttributeConverterTruncation, ShortStringUnderLimitUnchanged) +{ + opentelemetry::sdk::common::AttributeConverter converter(100); + auto v = converter(opentelemetry::nostd::string_view("short")); + EXPECT_EQ(opentelemetry::nostd::get(v), "short"); +} diff --git a/sdk/test/logs/BUILD b/sdk/test/logs/BUILD index ccf035c163..8e2198c318 100644 --- a/sdk/test/logs/BUILD +++ b/sdk/test/logs/BUILD @@ -81,6 +81,22 @@ cc_test( ], ) +cc_test( + name = "log_record_limits_test", + srcs = [ + "log_record_limits_test.cc", + ], + tags = [ + "logs", + "test", + ], + deps = [ + "//api", + "//sdk/src/logs", + "@com_google_googletest//:gtest_main", + ], +) + cc_test( name = "batch_log_record_processor_test", srcs = [ diff --git a/sdk/test/logs/CMakeLists.txt b/sdk/test/logs/CMakeLists.txt index 8ef79df68f..1fcbc3bb27 100644 --- a/sdk/test/logs/CMakeLists.txt +++ b/sdk/test/logs/CMakeLists.txt @@ -7,6 +7,7 @@ foreach( logger_provider_sdk_test logger_sdk_test log_record_test + log_record_limits_test simple_log_record_processor_test batch_log_record_processor_test logger_config_test) diff --git a/sdk/test/logs/log_record_limits_test.cc b/sdk/test/logs/log_record_limits_test.cc new file mode 100644 index 0000000000..cef7958b27 --- /dev/null +++ b/sdk/test/logs/log_record_limits_test.cc @@ -0,0 +1,348 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "opentelemetry/common/attribute_value.h" +#include "opentelemetry/common/timestamp.h" +#include "opentelemetry/logs/log_record.h" +#include "opentelemetry/logs/logger.h" +#include "opentelemetry/nostd/span.h" +#include "opentelemetry/nostd/string_view.h" +#include "opentelemetry/nostd/variant.h" +#include "opentelemetry/sdk/instrumentationscope/scope_configurator.h" +#include "opentelemetry/sdk/logs/log_record_limits.h" +#include "opentelemetry/sdk/logs/logger_config.h" +#include "opentelemetry/sdk/logs/logger_provider.h" +#include "opentelemetry/sdk/logs/logger_provider_factory.h" +#include "opentelemetry/sdk/logs/processor.h" +#include "opentelemetry/sdk/logs/read_write_log_record.h" +#include "opentelemetry/sdk/logs/recordable.h" +#include "opentelemetry/sdk/resource/resource.h" + +using opentelemetry::sdk::logs::LogRecordLimits; +using opentelemetry::sdk::logs::ReadWriteLogRecord; +namespace nostd = opentelemetry::nostd; + +TEST(LogRecordLimits, DefaultRecordAppliesNoLimitUntilConfigured) +{ + // A ReadWriteLogRecord that never receives an explicit SetLogRecordLimits() + // call applies no cap. The spec-default count limit is injected by the + // LoggerProvider wiring, not by a bare recordable, so a recordable used + // outside a provider keeps every attribute (matching pre-limits behavior). + ReadWriteLogRecord record; + for (int i = 0; i < 200; ++i) + { + record.SetAttribute("attr_" + std::to_string(i), static_cast(i)); + } + ASSERT_EQ(record.GetAttributes().size(), 200u); + ASSERT_EQ(record.GetDroppedAttributesCount(), 0u); +} + +TEST(LogRecordLimits, CountLimitDropsExcessAttributes) +{ + LogRecordLimits limits; + limits.attribute_count_limit = 3; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("k1", static_cast(1)); + record.SetAttribute("k2", static_cast(2)); + record.SetAttribute("k3", static_cast(3)); + record.SetAttribute("k4", static_cast(4)); + record.SetAttribute("k5", static_cast(5)); + + ASSERT_EQ(record.GetAttributes().size(), 3u); + ASSERT_EQ(record.GetDroppedAttributesCount(), 2u); + ASSERT_TRUE(record.GetAttributes().count("k1") == 1); + ASSERT_TRUE(record.GetAttributes().count("k2") == 1); + ASSERT_TRUE(record.GetAttributes().count("k3") == 1); + ASSERT_TRUE(record.GetAttributes().count("k4") == 0); + ASSERT_TRUE(record.GetAttributes().count("k5") == 0); +} + +TEST(LogRecordLimits, CountLimitReplaceExistingKeyDoesNotDrop) +{ + LogRecordLimits limits; + limits.attribute_count_limit = 2; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("k1", static_cast(1)); + record.SetAttribute("k2", static_cast(2)); + // Replace existing key while at limit; must not increment dropped count. + record.SetAttribute("k1", static_cast(99)); + + ASSERT_EQ(record.GetAttributes().size(), 2u); + ASSERT_EQ(record.GetDroppedAttributesCount(), 0u); + ASSERT_EQ(nostd::get(record.GetAttributes().at("k1")), 99); +} + +TEST(LogRecordLimits, LengthLimitTruncatesString) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 5; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("k", nostd::string_view("0123456789")); + + ASSERT_EQ(record.GetDroppedAttributesCount(), 0u); + ASSERT_EQ(nostd::get(record.GetAttributes().at("k")), "01234"); +} + +TEST(LogRecordLimits, LengthLimitTruncatesConstCharPtr) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 5; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + // A const char* attribute value goes through the AttributeConverter + // const char* overload (which delegates to the string_view path) and must + // be truncated the same way. + const char *value = "0123456789"; + record.SetAttribute("k", value); + + ASSERT_EQ(record.GetDroppedAttributesCount(), 0u); + ASSERT_EQ(nostd::get(record.GetAttributes().at("k")), "01234"); +} + +TEST(LogRecordLimits, LengthLimitTruncatesEachStringInArray) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 3; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + nostd::string_view values[] = {nostd::string_view("aaaaaa"), nostd::string_view("bb"), + nostd::string_view("cccc")}; + record.SetAttribute("k", nostd::span(values, 3)); + + const auto &stored = nostd::get>(record.GetAttributes().at("k")); + ASSERT_EQ(stored.size(), 3u); + ASSERT_EQ(stored[0], "aaa"); + ASSERT_EQ(stored[1], "bb"); + ASSERT_EQ(stored[2], "ccc"); +} + +TEST(LogRecordLimits, LengthLimitKeepsUtf8Boundary) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 2; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + // "h\xC3\xA9llo" is "héllo"; 'é' occupies bytes 1-2. A budget of 2 must drop + // the partial multi-byte sequence and keep just "h", not a split byte that + // would leave the stored value as invalid UTF-8. + record.SetAttribute("k", nostd::string_view("h\xC3\xA9llo")); + + ASSERT_EQ(nostd::get(record.GetAttributes().at("k")), "h"); +} + +TEST(LogRecordLimits, LengthLimitDoesNotAffectNonStringTypes) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 1; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("i", static_cast(1234567890)); + record.SetAttribute("d", 3.14); + record.SetAttribute("b", true); + + ASSERT_EQ(nostd::get(record.GetAttributes().at("i")), 1234567890); + ASSERT_DOUBLE_EQ(nostd::get(record.GetAttributes().at("d")), 3.14); + ASSERT_EQ(nostd::get(record.GetAttributes().at("b")), true); +} + +TEST(LogRecordLimits, LengthLimitShorterThanValueLeavesValueAlone) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 100; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("k", nostd::string_view("short")); + + ASSERT_EQ(nostd::get(record.GetAttributes().at("k")), "short"); +} + +TEST(LogRecordLimits, CountAndLengthCombined) +{ + LogRecordLimits limits; + limits.attribute_count_limit = 2; + limits.attribute_value_length_limit = 4; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + record.SetAttribute("k1", nostd::string_view("abcdefgh")); + record.SetAttribute("k2", nostd::string_view("12345678")); + record.SetAttribute("k3", nostd::string_view("ignored")); + + ASSERT_EQ(record.GetAttributes().size(), 2u); + ASSERT_EQ(record.GetDroppedAttributesCount(), 1u); + ASSERT_EQ(nostd::get(record.GetAttributes().at("k1")), "abcd"); + ASSERT_EQ(nostd::get(record.GetAttributes().at("k2")), "1234"); +} + +TEST(LogRecordLimits, LengthLimitTruncatesBytesAttribute) +{ + LogRecordLimits limits; + limits.attribute_value_length_limit = 3; + ReadWriteLogRecord record; + record.SetLogRecordLimits(limits); + + const uint8_t bytes_in[] = {0x01, 0x02, 0x03, 0x04, 0x05}; + record.SetAttribute("k", nostd::span(bytes_in, 5)); + + const auto &stored = nostd::get>(record.GetAttributes().at("k")); + ASSERT_EQ(stored.size(), 3u); + ASSERT_EQ(stored[0], 0x01); + ASSERT_EQ(stored[1], 0x02); + ASSERT_EQ(stored[2], 0x03); +} + +namespace +{ + +// Minimal Recordable that records whether SetLogRecordLimits was called and +// what limits arrived. Stubs every other Recordable method. +class TrackingRecordable final : public opentelemetry::sdk::logs::Recordable +{ +public: + int set_limits_call_count = 0; + std::size_t captured_count_limit = 0; + std::size_t captured_value_length_limit = 0; + + void SetLogRecordLimits(const LogRecordLimits &limits) noexcept override + { + ++set_limits_call_count; + captured_count_limit = limits.attribute_count_limit; + captured_value_length_limit = limits.attribute_value_length_limit; + } + + void SetResource(const opentelemetry::sdk::resource::Resource &) noexcept override {} + void SetInstrumentationScope( + const opentelemetry::sdk::instrumentationscope::InstrumentationScope &) noexcept override + {} + void SetTimestamp(opentelemetry::common::SystemTimestamp) noexcept override {} + void SetObservedTimestamp(opentelemetry::common::SystemTimestamp) noexcept override {} + void SetSeverity(opentelemetry::logs::Severity) noexcept override {} + void SetBody(const opentelemetry::common::AttributeValue &) noexcept override {} + void SetEventId(int64_t, nostd::string_view) noexcept override {} + void SetTraceId(const opentelemetry::trace::TraceId &) noexcept override {} + void SetSpanId(const opentelemetry::trace::SpanId &) noexcept override {} + void SetTraceFlags(const opentelemetry::trace::TraceFlags &) noexcept override {} + void SetAttribute(nostd::string_view, + const opentelemetry::common::AttributeValue &) noexcept override + {} +}; + +// Processor that hands out TrackingRecordable instances and exposes a pointer +// to the most recently produced one for inspection. +class TrackingProcessor final : public opentelemetry::sdk::logs::LogRecordProcessor +{ +public: + explicit TrackingProcessor(bool enforces_limits = true) : enforces_limits_(enforces_limits) {} + + TrackingRecordable *last = nullptr; + + std::unique_ptr MakeRecordable() noexcept override + { + auto recordable = std::unique_ptr(new TrackingRecordable()); + last = recordable.get(); + return recordable; + } + + void OnEmit(std::unique_ptr &&record) noexcept override + { + auto dropped = std::move(record); + } + + bool ForceFlush(std::chrono::microseconds) noexcept override { return true; } + + bool Shutdown(std::chrono::microseconds) noexcept override { return true; } + + bool RecordableEnforcesLogRecordLimits() const noexcept override { return enforces_limits_; } + +private: + bool enforces_limits_; +}; + +} // namespace + +TEST(LogRecordLimitsWiring, LoggerForwardsContextLimitsToRecordable) +{ + using opentelemetry::sdk::instrumentationscope::ScopeConfigurator; + using opentelemetry::sdk::logs::LoggerConfig; + using opentelemetry::sdk::logs::LoggerProviderFactory; + using opentelemetry::sdk::logs::LogRecordProcessor; + + LogRecordLimits limits; + limits.attribute_count_limit = 7; + limits.attribute_value_length_limit = 42; + + std::vector> processors; + // Processor advertises that its recordable enforces limits (the default), so + // the Logger pushes the context limits onto each record it creates. + auto tracking_processor = std::unique_ptr(new TrackingProcessor(true)); + auto *tracker_ptr = tracking_processor.get(); + processors.push_back(std::move(tracking_processor)); + + auto configurator = std::make_unique>( + ScopeConfigurator::Builder(LoggerConfig::Default()).Build()); + + auto provider = LoggerProviderFactory::Create(std::move(processors), + opentelemetry::sdk::resource::Resource::Create({}), + std::move(configurator), limits); + + auto logger = provider->GetLogger("wiring_test", ""); + auto record = logger->CreateLogRecord(); + + ASSERT_NE(tracker_ptr->last, nullptr); + EXPECT_EQ(tracker_ptr->last->set_limits_call_count, 1); + EXPECT_EQ(tracker_ptr->last->captured_count_limit, 7u); + EXPECT_EQ(tracker_ptr->last->captured_value_length_limit, 42u); +} + +TEST(LogRecordLimitsWiring, LoggerSkipsLimitsWhenProcessorDoesNotEnforce) +{ + using opentelemetry::sdk::instrumentationscope::ScopeConfigurator; + using opentelemetry::sdk::logs::LoggerConfig; + using opentelemetry::sdk::logs::LoggerProviderFactory; + using opentelemetry::sdk::logs::LogRecordProcessor; + + LogRecordLimits limits; + limits.attribute_count_limit = 7; + limits.attribute_value_length_limit = 42; + + std::vector> processors; + // Processor reports that its recordable ignores limits, so the Logger must + // skip the per-record SetLogRecordLimits() call on the hot path. + auto tracking_processor = std::unique_ptr(new TrackingProcessor(false)); + auto *tracker_ptr = tracking_processor.get(); + processors.push_back(std::move(tracking_processor)); + + auto configurator = std::make_unique>( + ScopeConfigurator::Builder(LoggerConfig::Default()).Build()); + + auto provider = LoggerProviderFactory::Create(std::move(processors), + opentelemetry::sdk::resource::Resource::Create({}), + std::move(configurator), limits); + + auto logger = provider->GetLogger("wiring_test", ""); + auto record = logger->CreateLogRecord(); + + ASSERT_NE(tracker_ptr->last, nullptr); + EXPECT_EQ(tracker_ptr->last->set_limits_call_count, 0); +}