diff --git a/docs/OtlpHttpLogger.md b/docs/OtlpHttpLogger.md index 2fda1b1..4d0879d 100644 --- a/docs/OtlpHttpLogger.md +++ b/docs/OtlpHttpLogger.md @@ -60,7 +60,49 @@ LogIt++ -> OtlpHttpLogger -> kurlyk::HttpClient -> OpenTelemetry Collector / Lok | `file`, `line`, `function` | `code.file.path`, `code.line.number`, `code.function.name` | | `thread_id` | `thread.id` | | `format` | `logit.format` | -| `arg_names` | `logit.arg_names` | +| `arg_names` | `logit.arg_names` (legacy, deprecated) | +| `args_array` elements | typed attributes under `logit.arg.*` prefix | + +## Structured typed attributes + +When `include_args = true` (the default), each element of `args_array` is emitted as a separate OTLP attribute with a typed value. The key is built from `args_prefix` + the sanitized argument name. + +### Configuration flags + +| Flag | Default | Description | +| --- | --- | --- | +| `include_args` | `true` | Emit structured typed arg attributes. | +| `include_arg_names` | `false` | Emit legacy `logit.arg_names` string attribute. | +| `args_prefix` | `"logit.arg."` | Key prefix for structured arg attributes. | + +### Type mapping + +| VariableValue type | OTLP AnyValue field | +| --- | --- | +| `BOOL_VAL` | `boolValue` | +| `INT8_VAL`..`INT64_VAL` | `intValue` | +| `UINT8_VAL`..`UINT32_VAL` | `intValue` | +| `UINT64_VAL` | `intValue` if <= INT64_MAX, else `stringValue` | +| `FLOAT_VAL`, `DOUBLE_VAL`, `LONG_DOUBLE_VAL` | `doubleValue` if finite, else `stringValue` | +| All other types | `stringValue` (via `to_string()`) | + +### Name sanitization and deduplication + +Argument names are sanitized: characters that are not alphanumeric, `_`, `.`, or `-` are replaced with `_`. If the sanitized name is empty or consists only of underscores, a positional key (`args_prefix` + index) is used instead. + +Duplicate keys are resolved by appending `.N` (starting at 1 for the second occurrence). For example, two args both named `px` produce keys `logit.arg.px` and `logit.arg.px.1`. + +### Reserved prefix + +The default prefix `logit.arg.` is reserved. Changing `args_prefix` is supported but may cause attribute collisions with other OTLP receivers. + +### Cardinality warning + +Avoid putting unique values (timestamps, request IDs, UUIDs) into arg attributes. High-cardinality attributes increase memory and storage costs in OTLP collectors and backends (Loki, Tempo, etc.). Use structured attributes for low-cardinality dimensions like `symbol`, `side`, or `region`. + +### Deprecation notice + +The `logit.arg_names` OTLP attribute (controlled by `include_arg_names`) is deprecated. It emits argument names as a single comma-separated string with no type information. Prefer `include_args = true` for typed, queryable attributes. Resource attributes are configured through `OtlpHttpLoggerConfig`, including `service.name`, `service.namespace`, `service.instance.id`, and `deployment.environment.name`. diff --git a/include/logit_cpp/logit/loggers/WindowsDebugLogger.hpp b/include/logit_cpp/logit/loggers/WindowsDebugLogger.hpp index 87906cb..b771e0f 100644 --- a/include/logit_cpp/logit/loggers/WindowsDebugLogger.hpp +++ b/include/logit_cpp/logit/loggers/WindowsDebugLogger.hpp @@ -83,7 +83,7 @@ namespace logit { /// \details This call is blocking. If asynchronous logging is currently enabled, /// it waits until already accepted tasks are drained before changing async mode /// or executor ownership. Only async, use_dedicated_executor, queue_capacity, - /// and queue_policy are applied. File/rotation/compression fields are ignored. + /// and queue_policy are applied. Other fields in Config are ignored. /// For hot queue tuning without switching executor mode, use set_queue_config(). void set_config(const Config& config) { std::unique_ptr old_executor; diff --git a/include/logit_cpp/logit/loggers/otlp/OtlpHttpLoggerConfig.hpp b/include/logit_cpp/logit/loggers/otlp/OtlpHttpLoggerConfig.hpp index 98b4c69..ebe1c52 100644 --- a/include/logit_cpp/logit/loggers/otlp/OtlpHttpLoggerConfig.hpp +++ b/include/logit_cpp/logit/loggers/otlp/OtlpHttpLoggerConfig.hpp @@ -36,7 +36,9 @@ namespace logit { bool include_source = true; ///< Export source file, line, and function attributes. bool include_thread_id = true; ///< Export thread id attribute. bool include_format = true; ///< Export original format string as `logit.format`. - bool include_arg_names = true; ///< Export original argument names as `logit.arg_names`. + bool include_arg_names = false; ///< Export original argument names as `logit.arg_names` (legacy). + bool include_args = true; ///< Export structured typed arg attributes. + std::string args_prefix = "logit.arg."; ///< Key prefix for structured arg attributes. }; } // namespace logit diff --git a/include/logit_cpp/logit/loggers/otlp/OtlpJsonSerializer.hpp b/include/logit_cpp/logit/loggers/otlp/OtlpJsonSerializer.hpp index 09d91c5..7c8275f 100644 --- a/include/logit_cpp/logit/loggers/otlp/OtlpJsonSerializer.hpp +++ b/include/logit_cpp/logit/loggers/otlp/OtlpJsonSerializer.hpp @@ -7,9 +7,14 @@ #include "OtlpHttpLoggerConfig.hpp" #include "OtlpRecordSnapshot.hpp" +#include #include +#include +#include +#include #include #include +#include #include namespace logit { @@ -107,6 +112,48 @@ namespace logit { << "\",\"value\":{\"boolValue\":" << (value ? "true" : "false") << "}}"; } + /// \brief Writes a double OTLP attribute. + /// \param os Output stream. + /// \param key Attribute key. + /// \param value Attribute double value. + inline void otlp_write_double_attr(std::ostringstream& os, const std::string& key, double value) { + if (std::isfinite(value)) { + os << "{\"key\":\"" << otlp_json_escape(key) + << "\",\"value\":{\"doubleValue\":" + << std::setprecision(std::numeric_limits::max_digits10) + << value << "}}"; + } else { + os << "{\"key\":\"" << otlp_json_escape(key) + << "\",\"value\":{\"stringValue\":\"" << otlp_json_escape(std::to_string(value)) << "\"}}"; + } + } + + /// \brief Writes a uint64 OTLP attribute. + /// \param os Output stream. + /// \param key Attribute key. + /// \param value Attribute uint64 value. + inline void otlp_write_uint_attr(std::ostringstream& os, const std::string& key, uint64_t value) { + if (value <= static_cast((std::numeric_limits::max)())) { + otlp_write_int_attr(os, key, static_cast(value)); + } else { + os << "{\"key\":\"" << otlp_json_escape(key) + << "\",\"value\":{\"stringValue\":\"" << value << "\"}}"; + } + } + + /// \brief Sanitizes an attribute key for OTLP. + /// \param name Raw key name. + /// \return Sanitized key with invalid chars replaced by underscore. + inline std::string sanitize_otlp_key(const std::string& name) { + std::string result = name; + for (char& c : result) { + if (!std::isalnum(static_cast(c)) && c != '_' && c != '.' && c != '-') { + c = '_'; + } + } + return result; + } + /// \brief Writes one OTLP JSON log record. /// \param os Output stream. /// \param item Log item to serialize. @@ -153,6 +200,82 @@ namespace logit { otlp_write_string_attr(os, "logit.arg_names", r.arg_names); } + if (config.include_args && !r.args_array.empty()) { + std::unordered_map key_count; + + for (std::size_t i = 0; i < r.args_array.size(); ++i) { + const VariableValue& arg = r.args_array[i]; + + std::string sanitized = sanitize_otlp_key(arg.name); + + bool all_underscore = true; + for (std::size_t j = 0; j < sanitized.size(); ++j) { + if (sanitized[j] != '_') { + all_underscore = false; + break; + } + } + + std::string key; + if (sanitized.empty() || all_underscore) { + key = config.args_prefix + std::to_string(i); + } else { + key = config.args_prefix + sanitized; + } + + auto it = key_count.find(key); + if (it != key_count.end()) { + ++(it->second); + key += "." + std::to_string(it->second); + } + key_count[key] = 0; + + otlp_write_comma_if_needed(os, first); + + switch (arg.type) { + case VariableValue::ValueType::BOOL_VAL: + otlp_write_bool_attr(os, key, arg.pod_value.bool_value); + break; + case VariableValue::ValueType::INT8_VAL: + otlp_write_int_attr(os, key, static_cast(arg.pod_value.int8_value)); + break; + case VariableValue::ValueType::INT16_VAL: + otlp_write_int_attr(os, key, static_cast(arg.pod_value.int16_value)); + break; + case VariableValue::ValueType::INT32_VAL: + otlp_write_int_attr(os, key, static_cast(arg.pod_value.int32_value)); + break; + case VariableValue::ValueType::INT64_VAL: + otlp_write_int_attr(os, key, arg.pod_value.int64_value); + break; + case VariableValue::ValueType::UINT8_VAL: + otlp_write_uint_attr(os, key, static_cast(arg.pod_value.uint8_value)); + break; + case VariableValue::ValueType::UINT16_VAL: + otlp_write_uint_attr(os, key, static_cast(arg.pod_value.uint16_value)); + break; + case VariableValue::ValueType::UINT32_VAL: + otlp_write_uint_attr(os, key, static_cast(arg.pod_value.uint32_value)); + break; + case VariableValue::ValueType::UINT64_VAL: + otlp_write_uint_attr(os, key, arg.pod_value.uint64_value); + break; + case VariableValue::ValueType::FLOAT_VAL: + otlp_write_double_attr(os, key, static_cast(arg.pod_value.float_value)); + break; + case VariableValue::ValueType::DOUBLE_VAL: + otlp_write_double_attr(os, key, arg.pod_value.double_value); + break; + case VariableValue::ValueType::LONG_DOUBLE_VAL: + otlp_write_double_attr(os, key, static_cast(arg.pod_value.long_double_value)); + break; + default: + otlp_write_string_attr(os, key, arg.to_string()); + break; + } + } + } + otlp_write_comma_if_needed(os, first); otlp_write_int_attr(os, "logit.logger_index", r.logger_index); diff --git a/include/logit_cpp/logit/loggers/otlp/OtlpRecordSnapshot.hpp b/include/logit_cpp/logit/loggers/otlp/OtlpRecordSnapshot.hpp index b2c6e83..382fe66 100644 --- a/include/logit_cpp/logit/loggers/otlp/OtlpRecordSnapshot.hpp +++ b/include/logit_cpp/logit/loggers/otlp/OtlpRecordSnapshot.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace logit { @@ -22,6 +23,7 @@ namespace logit { std::string function; ///< Source function name. std::string format; ///< Original message or format string. std::string arg_names; ///< Original argument names. + std::vector args_array; ///< Structured argument values. std::string thread_id; ///< Stringified std::thread::id. int logger_index = -1; ///< Target logger index, or -1 for all. bool print_mode = false; ///< Raw argument print mode flag. @@ -50,6 +52,7 @@ namespace logit { out.function = record.function; out.format = record.format; out.arg_names = record.arg_names; + out.args_array = record.args_array; out.thread_id = otlp_thread_id_to_string(record.thread_id); out.logger_index = record.logger_index; out.print_mode = record.print_mode; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a534c45..51623bc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,6 +51,7 @@ else() os_error_macros_test.cpp otlp_http_logger_integration_test.cpp otlp_json_serializer_test.cpp + otlp_structured_attributes_test.cpp per_logger_isolation_test.cpp per_logger_mixed_mode_test.cpp printf_format_macros_test.cpp @@ -78,6 +79,7 @@ else() endif() if(NOT LOGIT_WITH_OTLP) list(REMOVE_ITEM TEST_SOURCES otlp_http_logger_integration_test.cpp) + list(REMOVE_ITEM TEST_SOURCES otlp_structured_attributes_test.cpp) endif() foreach(test_src ${TEST_SOURCES}) get_filename_component(test_name ${test_src} NAME_WE) diff --git a/tests/otlp_json_serializer_test.cpp b/tests/otlp_json_serializer_test.cpp index b83beea..b51c7da 100644 --- a/tests/otlp_json_serializer_test.cpp +++ b/tests/otlp_json_serializer_test.cpp @@ -5,6 +5,7 @@ int main() { logit::OtlpHttpLoggerConfig config; + config.include_arg_names = true; config.service_name = "trade-bot"; config.service_namespace = "tests"; config.service_instance_id = "instance-1"; diff --git a/tests/otlp_structured_attributes_test.cpp b/tests/otlp_structured_attributes_test.cpp new file mode 100644 index 0000000..4e4ced3 --- /dev/null +++ b/tests/otlp_structured_attributes_test.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool json_contains(const std::string& json, const std::string& fragment) { + return json.find(fragment) != std::string::npos; +} + +bool json_not_contains(const std::string& json, const std::string& fragment) { + return json.find(fragment) == std::string::npos; +} + +logit::OtlpLogItem make_item_with_args( + const std::vector& args) { + logit::OtlpLogItem item; + item.record.log_level = logit::LogLevel::LOG_LVL_INFO; + item.record.timestamp_ms = 1710000000123LL; + item.record.file = "test.cpp"; + item.record.line = 1; + item.record.function = "test_func"; + item.record.format = "test"; + item.record.logger_index = -1; + item.record.print_mode = false; + item.record.fmt_mode = false; + item.record.raw_mode = false; + item.record.args_array = args; + item.message = "test message"; + return item; +} + +std::string serialize_single(const logit::OtlpLogItem& item, + const logit::OtlpHttpLoggerConfig& config) { + std::vector batch; + batch.push_back(item); + return logit::build_otlp_logs_json_payload(batch, config); +} + +} // namespace + +int main() { + // string attr + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("sym", std::string("AAPL"))); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.sym\",\"value\":{\"stringValue\":\"AAPL\"}")); + } + + // int attr + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("vol", 100)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.vol\",\"value\":{\"intValue\":\"100\"}")); + } + + // uint64 > INT64_MAX + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("ts", 18446744073709551615ULL)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.ts\",\"value\":{\"stringValue\":\"18446744073709551615\"}")); + } + + // double finite + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("px", 3.14)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.px\",\"value\":{\"doubleValue\":3.14}")); + } + + // double NaN + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("bad", NAN)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.bad\",\"value\":{\"stringValue\"")); + assert(json_not_contains(json, "\"key\":\"logit.arg.bad\",\"value\":{\"doubleValue\"")); + } + + // bool attr + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("ok", true)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.ok\",\"value\":{\"boolValue\":true}")); + } + + // char attr (stored as string, serializer emits stringValue) + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("ch", std::string("x"))); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.ch\",\"value\":{\"stringValue\":\"x\"}")); + } + + // enum attr + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + enum Color { RED = 2, GREEN = 5 }; + std::vector args; + args.push_back(logit::VariableValue("color", Color::GREEN)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.color\",\"value\":{\"stringValue\":\"5\"}")); + } + + // duplicate names + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("px", 1)); + args.push_back(logit::VariableValue("px", 2)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.px\",\"value\":{\"intValue\":\"1\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.px.1\",\"value\":{\"intValue\":\"2\"}")); + } + + // three duplicates + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("px", 1)); + args.push_back(logit::VariableValue("px", 2)); + args.push_back(logit::VariableValue("px", 3)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.px\",\"value\":{\"intValue\":\"1\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.px.1\",\"value\":{\"intValue\":\"2\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.px.2\",\"value\":{\"intValue\":\"3\"}")); + } + + // dedup suffix vs natural name collision + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("a", 1)); + args.push_back(logit::VariableValue("a", 2)); + args.push_back(logit::VariableValue("a.1", 3)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.a\",\"value\":{\"intValue\":\"1\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.a.1\",\"value\":{\"intValue\":\"2\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.a.1.1\",\"value\":{\"intValue\":\"3\"}")); + } + + // empty names (positional fallback) + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("", 1)); + args.push_back(logit::VariableValue("", 2)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.0\",\"value\":{\"intValue\":\"1\"}")); + assert(json_contains(json, "\"key\":\"logit.arg.1\",\"value\":{\"intValue\":\"2\"}")); + } + + // sanitized invalid chars + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("a b", std::string("x"))); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"logit.arg.a_b\",\"value\":{\"stringValue\":\"x\"}")); + } + + // custom prefix + { + logit::OtlpHttpLoggerConfig config; + config.include_arg_names = false; + config.args_prefix = "user."; + std::vector args; + args.push_back(logit::VariableValue("sym", std::string("AAPL"))); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_contains(json, "\"key\":\"user.sym\",\"value\":{\"stringValue\":\"AAPL\"}")); + } + + // include_args=false, include_arg_names=false: no arg-related attributes + { + logit::OtlpHttpLoggerConfig config; + config.include_args = false; + config.include_arg_names = false; + std::vector args; + args.push_back(logit::VariableValue("x", 42)); + std::string json = serialize_single(make_item_with_args(args), config); + assert(json_not_contains(json, "logit.arg.x")); + assert(json_not_contains(json, "logit.arg_names")); + } + + // include_arg_names legacy (include_args=false) + { + logit::OtlpHttpLoggerConfig config; + config.include_args = false; + config.include_arg_names = true; + logit::OtlpLogItem item; + item.record.log_level = logit::LogLevel::LOG_LVL_INFO; + item.record.timestamp_ms = 1710000000123LL; + item.record.file = "test.cpp"; + item.record.line = 1; + item.record.function = "test_func"; + item.record.format = "test"; + item.record.arg_names = "x,y"; + item.record.logger_index = -1; + item.record.print_mode = false; + item.record.fmt_mode = false; + item.record.raw_mode = false; + item.message = "test"; + std::string json = serialize_single(item, config); + assert(json_contains(json, "\"key\":\"logit.arg_names\",\"value\":{\"stringValue\":\"x,y\"}")); + assert(json_not_contains(json, "logit.arg.")); + } + + return 0; +}