From 5a36a9036e1a8f5e3cbf09eb2d7b046f5361fabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Fri, 13 Feb 2026 15:48:11 +0300 Subject: [PATCH 01/11] fix(exporter): treat unicode charlists as OTLP string values Convert list values that are valid unicode charlists to `string_value` instead of `array_value` so log bodies are exported as readable text instead of byte arrays. Co-authored-by: Cursor --- .../src/otel_otlp_common.erl | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/opentelemetry_exporter/src/otel_otlp_common.erl b/apps/opentelemetry_exporter/src/otel_otlp_common.erl index ff99e7fe1..ea3095933 100644 --- a/apps/opentelemetry_exporter/src/otel_otlp_common.erl +++ b/apps/opentelemetry_exporter/src/otel_otlp_common.erl @@ -73,7 +73,12 @@ to_any_value(Value) when is_map(Value) -> to_any_value(Value) when is_tuple(Value) -> #{value => {array_value, to_array_value(tuple_to_list(Value))}}; to_any_value(Value) when is_list(Value) -> - to_array_or_kvlist(Value); + case is_unicode_charlist(Value) of + true -> + #{value => {string_value, unicode:characters_to_binary(Value)}}; + false -> + to_array_or_kvlist(Value) + end; to_any_value(Value) -> #{value => {string_value, to_binary(io_lib:format("~p", [Value]))}}. @@ -111,6 +116,19 @@ is_proplist([{K, _} | L]) when is_atom(K) ; is_binary(K) -> is_proplist(_) -> false. +is_unicode_charlist(Value) when is_list(Value) -> + try + case unicode:characters_to_binary(Value) of + Bin when is_binary(Bin) -> + true; + _ -> + false + end + catch + _:_ -> + false + end. + to_binary(Term) when is_atom(Term) -> erlang:atom_to_binary(Term, unicode); to_binary(Term) -> From 685389b35fb188166e072c389a487a8e5a9f0804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Fri, 13 Feb 2026 15:50:06 +0300 Subject: [PATCH 02/11] refactor(exporter): avoid boolean branching in charlist detection Use pattern matching on `{ok, Bin} | error` for unicode charlist conversion to keep list handling idiomatic and explicit in Erlang. Co-authored-by: Cursor --- .../src/otel_otlp_common.erl | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/opentelemetry_exporter/src/otel_otlp_common.erl b/apps/opentelemetry_exporter/src/otel_otlp_common.erl index ea3095933..e136d3dce 100644 --- a/apps/opentelemetry_exporter/src/otel_otlp_common.erl +++ b/apps/opentelemetry_exporter/src/otel_otlp_common.erl @@ -73,10 +73,10 @@ to_any_value(Value) when is_map(Value) -> to_any_value(Value) when is_tuple(Value) -> #{value => {array_value, to_array_value(tuple_to_list(Value))}}; to_any_value(Value) when is_list(Value) -> - case is_unicode_charlist(Value) of - true -> - #{value => {string_value, unicode:characters_to_binary(Value)}}; - false -> + case maybe_unicode_charlist(Value) of + {ok, Bin} -> + #{value => {string_value, Bin}}; + error -> to_array_or_kvlist(Value) end; to_any_value(Value) -> @@ -116,17 +116,17 @@ is_proplist([{K, _} | L]) when is_atom(K) ; is_binary(K) -> is_proplist(_) -> false. -is_unicode_charlist(Value) when is_list(Value) -> +maybe_unicode_charlist(Value) when is_list(Value) -> try case unicode:characters_to_binary(Value) of Bin when is_binary(Bin) -> - true; + {ok, Bin}; _ -> - false + error end catch _:_ -> - false + error end. to_binary(Term) when is_atom(Term) -> From e7f3600556f3b10efed64dff33ccfe839f52ddf4 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Mon, 2 Mar 2026 16:51:14 +0300 Subject: [PATCH 03/11] Adds log handler's fsm exporting state's clause on empty batch --- apps/opentelemetry_experimental/src/otel_log_handler.erl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/opentelemetry_experimental/src/otel_log_handler.erl b/apps/opentelemetry_experimental/src/otel_log_handler.erl index a0939c73a..e9cf7cc83 100644 --- a/apps/opentelemetry_experimental/src/otel_log_handler.erl +++ b/apps/opentelemetry_experimental/src/otel_log_handler.erl @@ -189,6 +189,11 @@ exporting(internal, export, Data=#data{exporter=Exporter, batch=Batch}) when map_size(Batch) =/= 0 -> _ = export(Exporter, Resource, Batch, Config), {next_state, idle, Data#data{batch=#{}}}; +exporting(internal, export, Data) -> + %% FIXME Was this clause missing? + %% If the batch is empty, transition to the idle state, reschedule the + %% export, and continue collecting the next batch. + {next_state, idle, Data}; exporting(EventType, EventContent, Data) -> handle_event(EventType, EventContent, Data). From ec1da3a3f89c181a2ca51e03a9753047e8b6904c Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Mon, 2 Mar 2026 17:32:18 +0300 Subject: [PATCH 04/11] Adds `flush` call --- .../src/otel_log_handler.erl | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/opentelemetry_experimental/src/otel_log_handler.erl b/apps/opentelemetry_experimental/src/otel_log_handler.erl index e9cf7cc83..05c290f08 100644 --- a/apps/opentelemetry_experimental/src/otel_log_handler.erl +++ b/apps/opentelemetry_experimental/src/otel_log_handler.erl @@ -22,7 +22,8 @@ -include_lib("kernel/include/logger.hrl"). -include_lib("opentelemetry_api/include/opentelemetry.hrl"). --export([start_link/2]). +-export([start_link/2, + flush/1]). -export([log/2, adding_handler/1, @@ -69,6 +70,11 @@ start_link(RegName, Config) -> gen_statem:start_link({local, RegName}, ?MODULE, [RegName, Config], []). +-spec flush(Config) -> ok when + Config :: config(). +flush(_Config=#{regname := Id}) -> + gen_statem:call(Id, flush). + -spec adding_handler(Config) -> {ok, Config} | {error, Reason} when Config :: config(), Reason :: term(). @@ -186,14 +192,12 @@ exporting(enter, _OldState, _Data) -> exporting(internal, export, Data=#data{exporter=Exporter, resource=Resource, config=Config, - batch=Batch}) when map_size(Batch) =/= 0 -> - _ = export(Exporter, Resource, Batch, Config), + batch=Batch}) -> + _ = map_size(Batch) =/= 0 andalso + export(Exporter, Resource, Batch, Config), + %% If the batch is empty we still got to transition to the idle state, + %% reschedule the export, and continue collecting the next batch. {next_state, idle, Data#data{batch=#{}}}; -exporting(internal, export, Data) -> - %% FIXME Was this clause missing? - %% If the batch is empty, transition to the idle state, reschedule the - %% export, and continue collecting the next batch. - {next_state, idle, Data}; exporting(EventType, EventContent, Data) -> handle_event(EventType, EventContent, Data). @@ -206,6 +210,13 @@ handle_event({call, From}, {filter_handler, Config}, Data) -> {keep_state, Data, [{reply, From, Config}]}; handle_event({call, From}, {filter_config, Config}, Data) -> {keep_state, Data, [{reply, From, Config}]}; +handle_event({call, From}, flush, Data=#data{exporter=Exporter, + resource=Resource, + config=Config, + batch=Batch}) -> + _ = map_size(Batch) =/= 0 andalso + export(Exporter, Resource, Batch, Config), + {keep_state, Data, [{reply, From, ok}]}; handle_event({call, _From}, _Msg, _Data) -> keep_state_and_data; handle_event(cast, {log, Scope, LogEvent}, Data=#data{batch=Logs}) -> From ddf1efb45e3d2f334ff4b78f0d482f2ab9d32428 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Mon, 2 Mar 2026 17:36:02 +0300 Subject: [PATCH 05/11] Refactors flushing --- .../src/otel_log_handler.erl | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/opentelemetry_experimental/src/otel_log_handler.erl b/apps/opentelemetry_experimental/src/otel_log_handler.erl index 05c290f08..109dbe1bc 100644 --- a/apps/opentelemetry_experimental/src/otel_log_handler.erl +++ b/apps/opentelemetry_experimental/src/otel_log_handler.erl @@ -189,33 +189,23 @@ exporting({timeout, export_logs}, export_logs, _) -> {keep_state_and_data, [postpone]}; exporting(enter, _OldState, _Data) -> keep_state_and_data; -exporting(internal, export, Data=#data{exporter=Exporter, - resource=Resource, - config=Config, - batch=Batch}) -> - _ = map_size(Batch) =/= 0 andalso - export(Exporter, Resource, Batch, Config), - %% If the batch is empty we still got to transition to the idle state, - %% reschedule the export, and continue collecting the next batch. +exporting(internal, export, Data) -> + ok = maybe_export(Data), {next_state, idle, Data#data{batch=#{}}}; exporting(EventType, EventContent, Data) -> handle_event(EventType, EventContent, Data). handle_event({call, From}, {changing_config, _SetOrUpdate, _OldConfig, NewConfig}, Data) -> {keep_state, Data#data{config=NewConfig}, [{reply, From, NewConfig}]}; -handle_event({call, From}, {removing_handler, Config}, _Data) -> - %% TODO: flush +handle_event({call, From}, {removing_handler, Config}, Data) -> + ok = maybe_export(Data), {keep_state_and_data, [{reply, From, Config}]}; handle_event({call, From}, {filter_handler, Config}, Data) -> {keep_state, Data, [{reply, From, Config}]}; handle_event({call, From}, {filter_config, Config}, Data) -> {keep_state, Data, [{reply, From, Config}]}; -handle_event({call, From}, flush, Data=#data{exporter=Exporter, - resource=Resource, - config=Config, - batch=Batch}) -> - _ = map_size(Batch) =/= 0 andalso - export(Exporter, Resource, Batch, Config), +handle_event({call, From}, flush, Data) -> + ok = maybe_export(Data), {keep_state, Data, [{reply, From, ok}]}; handle_event({call, _From}, _Msg, _Data) -> keep_state_and_data; @@ -236,6 +226,15 @@ init_exporter(ExporterConfig) -> undefined end. +maybe_export(Data=#data{exporter=Exporter, + resource=Resource, + config=Config, + batch=Batch}) when map_size(Batch) =/= 0 -> + _ = export(Exporter, Resource, Batch, Config), + ok; +maybe_export(_Data) -> + ok. + export(undefined, _, _, _) -> true; export(Exporter, Resource, Batch, Config) -> From 4801add5018649fdea180614f215958b34138a0b Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 25 Mar 2026 14:28:03 +0300 Subject: [PATCH 06/11] Reverts treatment of charlist attribute's value as unicode binary --- .../src/otel_otlp_common.erl | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/apps/opentelemetry_exporter/src/otel_otlp_common.erl b/apps/opentelemetry_exporter/src/otel_otlp_common.erl index e136d3dce..ff99e7fe1 100644 --- a/apps/opentelemetry_exporter/src/otel_otlp_common.erl +++ b/apps/opentelemetry_exporter/src/otel_otlp_common.erl @@ -73,12 +73,7 @@ to_any_value(Value) when is_map(Value) -> to_any_value(Value) when is_tuple(Value) -> #{value => {array_value, to_array_value(tuple_to_list(Value))}}; to_any_value(Value) when is_list(Value) -> - case maybe_unicode_charlist(Value) of - {ok, Bin} -> - #{value => {string_value, Bin}}; - error -> - to_array_or_kvlist(Value) - end; + to_array_or_kvlist(Value); to_any_value(Value) -> #{value => {string_value, to_binary(io_lib:format("~p", [Value]))}}. @@ -116,19 +111,6 @@ is_proplist([{K, _} | L]) when is_atom(K) ; is_binary(K) -> is_proplist(_) -> false. -maybe_unicode_charlist(Value) when is_list(Value) -> - try - case unicode:characters_to_binary(Value) of - Bin when is_binary(Bin) -> - {ok, Bin}; - _ -> - error - end - catch - _:_ -> - error - end. - to_binary(Term) when is_atom(Term) -> erlang:atom_to_binary(Term, unicode); to_binary(Term) -> From 9eacaab20718621149cb0881c4b70a3f2190232c Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 25 Mar 2026 16:02:40 +0300 Subject: [PATCH 07/11] Moves handler-specific config parameters into explicit sub map --- .../src/otel_log_handler.erl | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/opentelemetry_experimental/src/otel_log_handler.erl b/apps/opentelemetry_experimental/src/otel_log_handler.erl index 109dbe1bc..1ab741b9f 100644 --- a/apps/opentelemetry_experimental/src/otel_log_handler.erl +++ b/apps/opentelemetry_experimental/src/otel_log_handler.erl @@ -38,9 +38,18 @@ exporting/3, handle_event/3]). + +-type handler_config() :: #{max_queue_size => non_neg_integer(), + exporting_timeout_ms => non_neg_integer(), + scheduled_delay_ms => non_neg_integer(), + exporter => {module(), term()}, + report_cb => fun(), + depth => non_neg_integer(), + chars_limit => non_neg_integer(), + single_line => boolean()}. -type config() :: #{id => logger:handler_id(), regname := atom(), - config => term(), + config => handler_config(), level => logger:level() | all | none, module => module(), filter_default => log | stop, @@ -146,17 +155,17 @@ init([_RegName, Config]) -> process_flag(trap_exit, true), Resource = otel_resource_detector:get_resource(), + HandlerConfig = maps:get(config, Config, #{}), + SizeLimit = maps:get(max_queue_size, HandlerConfig, ?DEFAULT_MAX_QUEUE_SIZE), + ExportingTimeout = maps:get(exporting_timeout_ms, HandlerConfig, ?DEFAULT_EXPORTER_TIMEOUT_MS), + ScheduledDelay = maps:get(scheduled_delay_ms, HandlerConfig, ?DEFAULT_SCHEDULED_DELAY_MS), - SizeLimit = maps:get(max_queue_size, Config, ?DEFAULT_MAX_QUEUE_SIZE), - ExportingTimeout = maps:get(exporting_timeout_ms, Config, ?DEFAULT_EXPORTER_TIMEOUT_MS), - ScheduledDelay = maps:get(scheduled_delay_ms, Config, ?DEFAULT_SCHEDULED_DELAY_MS), - - ExporterConfig = maps:get(exporter, Config, {opentelemetry_exporter, #{protocol => grpc}}), + ExporterConfig = maps:get(exporter, HandlerConfig, {opentelemetry_exporter, #{protocol => grpc}}), {ok, idle, #data{exporter=undefined, exporter_config=ExporterConfig, resource=Resource, - config=Config, + config=HandlerConfig, max_queue_size=case SizeLimit of infinity -> infinity; _ -> SizeLimit div erlang:system_info(wordsize) @@ -196,12 +205,11 @@ exporting(EventType, EventContent, Data) -> handle_event(EventType, EventContent, Data). handle_event({call, From}, {changing_config, _SetOrUpdate, _OldConfig, NewConfig}, Data) -> - {keep_state, Data#data{config=NewConfig}, [{reply, From, NewConfig}]}; + HandlerConfig = maps:get(config, NewConfig, #{}), + {keep_state, Data#data{config=HandlerConfig}, [{reply, From, NewConfig}]}; handle_event({call, From}, {removing_handler, Config}, Data) -> ok = maybe_export(Data), {keep_state_and_data, [{reply, From, Config}]}; -handle_event({call, From}, {filter_handler, Config}, Data) -> - {keep_state, Data, [{reply, From, Config}]}; handle_event({call, From}, {filter_config, Config}, Data) -> {keep_state, Data, [{reply, From, Config}]}; handle_event({call, From}, flush, Data) -> From 4dff7a68ac872f435053b63be5dc8fa9ee6f46e2 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 25 Mar 2026 16:40:23 +0300 Subject: [PATCH 08/11] Makes formatted log message a binary instead of a charlist --- apps/opentelemetry_experimental/src/otel_otlp_logs.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl index 2eb908f01..613ada7c1 100644 --- a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl +++ b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl @@ -77,7 +77,7 @@ log_record(#{level := Level, lists:reverse( trim(S, false)), true)), re:replace(T,",?\r?\n\s*",", ", - [{return,list}, global, unicode]); + [{return,binary}, global, unicode]); M -> M end, From 4f3f219ae8dda86259346b622b3d13b841fceeb9 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 25 Mar 2026 16:42:24 +0300 Subject: [PATCH 09/11] Adds comment to re line --- apps/opentelemetry_experimental/src/otel_otlp_logs.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl index 613ada7c1..3cd753b78 100644 --- a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl +++ b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl @@ -76,6 +76,7 @@ log_record(#{level := Level, trim( lists:reverse( trim(S, false)), true)), + %% TODO Do we actually have to strip string of newlines with indentation? re:replace(T,",?\r?\n\s*",", ", [{return,binary}, global, unicode]); M -> From 8c87f66a123dc5046327716aff346f2f4800a5db Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Thu, 26 Mar 2026 14:18:39 +0300 Subject: [PATCH 10/11] Adds otel ids to bytes marshalling for log records Tests are still missing --- .../src/otel_otlp_logs.erl | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl index 3cd753b78..3b722a8a6 100644 --- a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl +++ b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl @@ -90,39 +90,42 @@ log_record(#{level := Level, DroppedAttributesCount = maps:size(Attributes) - length(Attributes1), Flags = 0, - %% Note: otel_trace_id and otel_span_id from hex_span_ctx are now binaries, not charlists. - LogRecord = case Metadata of - #{otel_trace_id := TraceId, - otel_span_id := SpanId, - otel_trace_flags := TraceFlagsHex} -> - TraceFlags = case TraceFlagsHex of - <<_:0, _/binary>> when byte_size(TraceFlagsHex) == 2 -> - erlang:binary_to_integer(TraceFlagsHex, 16); - _ -> 0 - end, - #{trace_id => TraceId, - span_id => SpanId, - trace_flags => TraceFlags}; - #{otel_trace_id := TraceId, - otel_span_id := SpanId} -> - #{trace_id => TraceId, - span_id => SpanId}; - _ -> - #{} - end, - - + %% NOTE: Collector wants IDs to be bytes (https://github.com/open-telemetry/opentelemetry-proto/blob/ca839c51f706f5d53bfb46f06c3e90c3af3a52c6/opentelemetry/proto/logs/v1/logs.proto#L199) + LogRecord = try_prepare_tracing_ids(Metadata), LogRecord#{time_unix_nano => opentelemetry:timestamp_to_nano(Time), observed_time_unix_nano => erlang:convert_time_unit(ObservedTime, microsecond, nanosecond), - severity_number => SeverityNumber, - severity_text => SeverityText, - body => otel_otlp_common:to_any_value(Body1), - attributes => Attributes1, + severity_number => SeverityNumber, + severity_text => SeverityText, + body => otel_otlp_common:to_any_value(Body1), + attributes => Attributes1, dropped_attributes_count => DroppedAttributesCount, - flags => Flags + flags => Flags }. +try_prepare_tracing_ids(#{otel_trace_id := TraceId, otel_span_id := SpanId, otel_trace_flags := TraceFlagsHex}) -> + try + TraceFlags = case TraceFlagsHex of + <<_:0, _/binary>> when byte_size(TraceFlagsHex) == 2 -> + erlang:binary_to_integer(TraceFlagsHex, 16); + _ -> 0 + end, + #{trace_id => <<(binary_to_integer(TraceId, 16)):128>>, + span_id => <<(binary_to_integer(SpanId, 16)):64>>, + trace_flags => TraceFlags} + catch + _:_ -> #{} + end; +try_prepare_tracing_ids(#{otel_trace_id := TraceId, otel_span_id := SpanId}) -> + try + #{trace_id => <<(binary_to_integer(TraceId, 16)):128>>, + span_id => <<(binary_to_integer(SpanId, 16)):64>>} + catch + _:_ -> #{} + end; +try_prepare_tracing_ids(_) -> + #{}. + format_msg({string, Chardata}, Meta, Config) -> format_msg({"~ts", [Chardata]}, Meta, Config); format_msg({report,_}=Msg, Meta, #{report_cb := Fun}=Config) From 21c41d1eb6dffb95d51c4b1cd89b38eae00fc08a Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Thu, 26 Mar 2026 16:40:23 +0300 Subject: [PATCH 11/11] Drops otel_* metadata attributes from log records --- apps/opentelemetry_experimental/src/otel_otlp_logs.erl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl index 3b722a8a6..bcd11f898 100644 --- a/apps/opentelemetry_experimental/src/otel_otlp_logs.erl +++ b/apps/opentelemetry_experimental/src/otel_otlp_logs.erl @@ -82,11 +82,12 @@ log_record(#{level := Level, M -> M end, - Attributes = maps:without([gl, time, report_cb], Metadata), + Attributes = maps:without([gl, time, report_cb, otel_trace_id, otel_span_id, otel_trace_flags], Metadata), Attributes1 = maps:fold(fun(K, V, Acc) -> [#{key => otel_otlp_common:to_binary(K), value => otel_otlp_common:to_any_value(V)} | Acc] end, [], Attributes), + %% FIXME Always 0? DroppedAttributesCount = maps:size(Attributes) - length(Attributes1), Flags = 0,