Skip to content

Commit e7eaaa8

Browse files
authored
feat(logs): support non-primitives in the attributes (#1014)
1 parent d6a8041 commit e7eaaa8

7 files changed

Lines changed: 212 additions & 13 deletions

File tree

lib/sentry/log_event.ex

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ defmodule Sentry.LogEvent do
149149
# Positional parameters: %s placeholders
150150
defp interpolate_template(message, parameters) when is_list(parameters) do
151151
# Convert parameters to proper types for storage
152-
processed_params = Enum.map(parameters, &stringify_parameter/1)
152+
processed_params = Enum.map(parameters, &sanitize_attribute_value/1)
153153

154154
# Interpolate %s placeholders
155155
body = interpolate_positional_placeholders(message, parameters)
@@ -166,7 +166,7 @@ defmodule Sentry.LogEvent do
166166
processed_params =
167167
Enum.map(keys, fn key ->
168168
value = Map.get(parameters, key) || Map.get(parameters, to_string(key))
169-
stringify_parameter(value)
169+
sanitize_attribute_value(value)
170170
end)
171171

172172
# Interpolate %{key} placeholders
@@ -209,14 +209,18 @@ defmodule Sentry.LogEvent do
209209
defp to_string_for_interpolation(value) when is_float(value), do: Float.to_string(value)
210210
defp to_string_for_interpolation(value), do: inspect(value)
211211

212-
# Convert parameter values to a form suitable for Sentry attributes
212+
# Converts values to JSON-safe attribute types.
213+
# Primitives (string, boolean, integer, float) pass through unchanged.
214+
# Atoms are converted to strings. All other types (structs, maps, lists,
215+
# tuples, PIDs, etc.) are converted to their inspect() representation.
216+
# Used for both message template parameters and user-provided attributes.
213217
# Note: is_boolean must come before is_atom since true/false are atoms
214-
defp stringify_parameter(value) when is_binary(value), do: value
215-
defp stringify_parameter(value) when is_boolean(value), do: value
216-
defp stringify_parameter(value) when is_atom(value), do: Atom.to_string(value)
217-
defp stringify_parameter(value) when is_integer(value), do: value
218-
defp stringify_parameter(value) when is_float(value), do: value
219-
defp stringify_parameter(value), do: inspect(value)
218+
defp sanitize_attribute_value(value) when is_binary(value), do: value
219+
defp sanitize_attribute_value(value) when is_boolean(value), do: value
220+
defp sanitize_attribute_value(value) when is_atom(value), do: Atom.to_string(value)
221+
defp sanitize_attribute_value(value) when is_integer(value), do: value
222+
defp sanitize_attribute_value(value) when is_float(value), do: value
223+
defp sanitize_attribute_value(value), do: inspect(value)
220224

221225
# Extract message body and optionally template/parameters
222226
# If user_params provided via metadata, use those for interpolation
@@ -248,7 +252,7 @@ defmodule Sentry.LogEvent do
248252
when is_list(format) and is_list(args) do
249253
body = format |> :io_lib.format(args) |> IO.chardata_to_string()
250254
template = IO.chardata_to_string(format)
251-
processed_params = Enum.map(args, &stringify_parameter/1)
255+
processed_params = Enum.map(args, &sanitize_attribute_value/1)
252256
{body, template, processed_params}
253257
end
254258

@@ -270,10 +274,11 @@ defmodule Sentry.LogEvent do
270274
defp extract_trace_context(_log_event), do: {nil, nil}
271275

272276
defp build_attributes(%__MODULE__{} = log_event) do
273-
# Start with user-provided attributes
277+
# Start with user-provided attributes, converting non-primitive values to strings
274278
formatted_attrs =
275279
Enum.into(log_event.attributes, %{}, fn {key, value} ->
276-
{to_string(key), %{value: value, type: attribute_type(value)}}
280+
safe_value = sanitize_attribute_value(value)
281+
{to_string(key), %{value: safe_value, type: attribute_type(safe_value)}}
277282
end)
278283

279284
# Add Sentry-specific attributes

test/sentry/log_event_test.exs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,5 +252,108 @@ defmodule Sentry.LogEventTest do
252252
refute Map.has_key?(result.attributes, "sentry.message.template")
253253
refute Map.has_key?(result.attributes, "sentry.message.parameter.0")
254254
end
255+
256+
test "safely serializes struct attribute values" do
257+
uri = URI.parse("https://example.com/path")
258+
259+
log_event = %LogEvent{
260+
level: :info,
261+
body: "test",
262+
timestamp: 1_000_000.5,
263+
attributes: %{uri: uri}
264+
}
265+
266+
result = LogEvent.to_map(log_event)
267+
268+
assert result.attributes["uri"] == %{value: inspect(uri), type: "string"}
269+
end
270+
271+
test "safely serializes map attribute values" do
272+
log_event = %LogEvent{
273+
level: :info,
274+
body: "test",
275+
timestamp: 1_000_000.5,
276+
attributes: %{data: %{nested: "value"}}
277+
}
278+
279+
result = LogEvent.to_map(log_event)
280+
281+
assert result.attributes["data"] == %{value: ~s(%{nested: "value"}), type: "string"}
282+
end
283+
284+
test "safely serializes list attribute values" do
285+
log_event = %LogEvent{
286+
level: :info,
287+
body: "test",
288+
timestamp: 1_000_000.5,
289+
attributes: %{items: [1, "two", :three]}
290+
}
291+
292+
result = LogEvent.to_map(log_event)
293+
294+
assert result.attributes["items"] == %{value: ~s([1, "two", :three]), type: "string"}
295+
end
296+
297+
test "safely serializes tuple attribute values" do
298+
log_event = %LogEvent{
299+
level: :info,
300+
body: "test",
301+
timestamp: 1_000_000.5,
302+
attributes: %{pair: {:ok, "done"}}
303+
}
304+
305+
result = LogEvent.to_map(log_event)
306+
307+
assert result.attributes["pair"] == %{value: ~s({:ok, "done"}), type: "string"}
308+
end
309+
310+
test "safely serializes PID attribute values" do
311+
pid = self()
312+
313+
log_event = %LogEvent{
314+
level: :info,
315+
body: "test",
316+
timestamp: 1_000_000.5,
317+
attributes: %{pid: pid}
318+
}
319+
320+
result = LogEvent.to_map(log_event)
321+
322+
assert result.attributes["pid"] == %{value: inspect(pid), type: "string"}
323+
end
324+
325+
test "converts atom attribute values to strings" do
326+
log_event = %LogEvent{
327+
level: :info,
328+
body: "test",
329+
timestamp: 1_000_000.5,
330+
attributes: %{status: :active}
331+
}
332+
333+
result = LogEvent.to_map(log_event)
334+
335+
assert result.attributes["status"] == %{value: "active", type: "string"}
336+
end
337+
338+
test "preserves primitive attribute values unchanged" do
339+
log_event = %LogEvent{
340+
level: :info,
341+
body: "test",
342+
timestamp: 1_000_000.5,
343+
attributes: %{
344+
name: "Alice",
345+
count: 42,
346+
price: 9.99,
347+
active: true
348+
}
349+
}
350+
351+
result = LogEvent.to_map(log_event)
352+
353+
assert result.attributes["name"] == %{value: "Alice", type: "string"}
354+
assert result.attributes["count"] == %{value: 42, type: "integer"}
355+
assert result.attributes["price"] == %{value: 9.99, type: "double"}
356+
assert result.attributes["active"] == %{value: true, type: "boolean"}
357+
end
255358
end
256359
end

test/sentry/logger_handler/logs_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,40 @@ defmodule Sentry.LoggerHandler.LogsTest do
189189
assert_receive :envelope_sent, 1000
190190
end
191191

192+
test "safely serializes struct metadata as string attributes", %{bypass: bypass} do
193+
test_pid = self()
194+
195+
Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn ->
196+
{:ok, body, conn} = Plug.Conn.read_body(conn)
197+
[_header, _item_header, item_body, _] = String.split(body, "\n")
198+
199+
item_body_map = decode!(item_body)
200+
assert %{"items" => [log_event]} = item_body_map
201+
202+
assert %{"my_uri" => %{"type" => "string", "value" => value}} =
203+
log_event["attributes"]
204+
205+
assert value == inspect(URI.parse("https://example.com/path"))
206+
207+
send(test_pid, :envelope_sent)
208+
209+
Plug.Conn.resp(conn, 200, ~s<{"id": "test-123"}>)
210+
end)
211+
212+
put_test_config(logs: [metadata: [:my_uri]])
213+
214+
TelemetryProcessor.flush()
215+
216+
Logger.metadata(my_uri: URI.parse("https://example.com/path"))
217+
Logger.info("Request with struct metadata")
218+
219+
assert_buffer_size(nil, 1)
220+
221+
TelemetryProcessor.flush()
222+
223+
assert_receive :envelope_sent, 1000
224+
end
225+
192226
test "includes all metadata when configured with :all" do
193227
put_test_config(logs: [metadata: :all])
194228

test_integrations/phoenix_app/config/dev.exs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ config :sentry,
9090
enable_source_code_context: true,
9191
send_result: :sync,
9292
traces_sample_rate: 1.0,
93-
enable_logs: true
93+
enable_logs: true,
94+
logs: [
95+
level: :info,
96+
metadata: :all
97+
]
9498

9599
config :phoenix_app, Oban,
96100
repo: PhoenixApp.Repo,

test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ defmodule PhoenixAppWeb.PageController do
9191
# 1. Start the Phoenix app: cd test_integrations/phoenix_app && iex -S mix phx.server
9292
# 2. Visit: http://localhost:4000/logs
9393
# 3. Check Sentry logs - they should have trace_id matching the transaction traces
94+
def logs_with_structs(conn, _params) do
95+
Logger.metadata(
96+
uri: URI.parse("https://example.com/path"),
97+
conn_info: %{method: conn.method, path: conn.request_path},
98+
tags: [:web, :test]
99+
)
100+
101+
Logger.info("Log with struct metadata")
102+
103+
Sentry.flush()
104+
105+
json(conn, %{message: "ok"})
106+
end
107+
94108
def logs_demo(conn, params) do
95109
request_id =
96110
get_req_header(conn, "x-request-id") |> List.first() || "demo-#{:rand.uniform(10000)}"

test_integrations/phoenix_app/lib/phoenix_app_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ defmodule PhoenixAppWeb.Router do
3434
get "/transaction", PageController, :transaction
3535
get "/nested-spans", PageController, :nested_spans
3636
get "/logs", PageController, :logs_demo
37+
get "/logs-with-structs", PageController, :logs_with_structs
3738

3839
live "/test-worker", TestWorkerLive
3940
live "/tracing-test", TracingTestLive

test_integrations/phoenix_app/test/phoenix_app_web/controllers/logs_test.exs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,44 @@ defmodule Sentry.Integrations.Phoenix.LogsTest do
104104
end
105105
end
106106

107+
describe "structured logging with complex metadata" do
108+
test "GET /logs-with-structs safely serializes struct attributes for JSON encoding", %{
109+
conn: conn
110+
} do
111+
put_test_config(logs: [level: :info, excluded_domains: [:cowboy, :ranch], metadata: :all])
112+
113+
get(conn, ~p"/logs-with-structs")
114+
115+
logs = Sentry.Test.pop_sentry_logs()
116+
117+
struct_log =
118+
Enum.find(logs, fn log ->
119+
String.contains?(log.body, "Log with struct metadata")
120+
end)
121+
122+
assert struct_log != nil
123+
124+
log_map = Sentry.LogEvent.to_map(struct_log)
125+
attrs = log_map.attributes
126+
127+
assert %{type: "string", value: uri_value} = attrs["uri"]
128+
assert uri_value == inspect(URI.parse("https://example.com/path"))
129+
130+
assert %{type: "string", value: conn_value} = attrs["conn_info"]
131+
assert conn_value =~ "method"
132+
133+
assert %{type: "string", value: tags_value} = attrs["tags"]
134+
assert tags_value == "[:web, :test]"
135+
136+
assert {:ok, json} = Sentry.JSON.encode(log_map, Sentry.Config.json_library())
137+
assert is_binary(json)
138+
139+
assert {:ok, decoded} = Sentry.JSON.decode(json, Sentry.Config.json_library())
140+
assert decoded["attributes"]["uri"]["value"] == uri_value
141+
assert decoded["attributes"]["tags"]["value"] == "[:web, :test]"
142+
end
143+
end
144+
107145
defp filter_app_logs(logs) do
108146
Enum.filter(logs, fn log ->
109147
body = log.body

0 commit comments

Comments
 (0)