diff --git a/README.md b/README.md index 49b5bf70..63e70dd7 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,41 @@ config :sentry, ### Usage -This library comes with a [`:logger` handler][logger-handlers] to capture error messages coming from process crashes. To enable this, add [`Sentry.LoggerHandler`](https://hexdocs.pm/sentry/Sentry.LoggerHandler.html) to your production configuration: +This library comes with a [`:logger` handler][logger-handlers], +[`Sentry.LoggerHandler`](https://hexdocs.pm/sentry/Sentry.LoggerHandler.html), that does two +things: it reports crashes (and, optionally, `Logger` messages) to Sentry as **error events**, +and it forwards log entries to [Sentry's Logs UI](https://develop.sentry.dev/sdk/telemetry/logs/) +as **structured logs**. + +The recommended way to enable it is to set `enable_logs: true` in your Sentry config. The SDK +then **attaches the handler automatically** — you don't need to touch your `:logger` +configuration or your `application.ex`: + +```elixir +# config/prod.exs +config :sentry, + # ...your other Sentry config... + enable_logs: true, + logs: [ + # Structured logs sent to Sentry's Logs UI: + level: :info, + metadata: [:request_id], + # Also turn standalone Logger messages into Sentry error events. + # Omit these to only report crashes as error events. + capture_log_messages: true, + capture_level: :error, + capture_metadata: [:request_id] + ] +``` + +With the configuration above, `Logger.info/1` and higher are sent to the Logs UI, while +`Logger.error/1` and higher are *also* captured as error events (crashes are always reported). + +#### Advanced: configuring the handler manually + +If you want full control over the handler's options (such as `:rate_limiting` or +`:tags_from_metadata`), or you want error reporting *without* structured logs, you can add the +handler yourself instead of using `enable_logs`: ```elixir # config/prod.exs @@ -78,7 +112,6 @@ config :my_app, :logger, [ } }} ] - ``` And then add your logger when your application starts: diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index d5629297..23f2f4f9 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -135,7 +135,21 @@ defmodule Sentry.Application do defp maybe_add_logger_handler do if Config.enable_logs?() do unless sentry_logger_handler_registered?() do - case :logger.add_handler(:sentry_log_handler, Sentry.LoggerHandler, %{config: %{}}) do + # The :logs config drives both backends of the auto-attached handler: LogsBackend + # reads its options (level, excluded_domains, metadata) from Config at runtime, + # while the ErrorBackend options are passed here at attach time. The error-event + # options come from the separate :capture_* keys so they stay independent from the + # Logs UI ones (e.g. error-event metadata/excluded_domains are opt-in). + handler_config = %{ + level: Config.logs_capture_level(), + capture_log_messages: Config.logs_capture_log_messages?(), + metadata: Config.logs_capture_metadata(), + excluded_domains: Config.logs_capture_excluded_domains() + } + + case :logger.add_handler(:sentry_log_handler, Sentry.LoggerHandler, %{ + config: handler_config + }) do :ok -> :ok diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index fb66eee5..7aade0dc 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -401,6 +401,10 @@ defmodule Sentry.Config do Whether to enable sending log events to Sentry. When enabled, the SDK will automatically attach a `Sentry.LoggerHandler` to capture and send structured log events according to the [Sentry Logs Protocol](https://develop.sentry.dev/sdk/telemetry/logs/). + The auto-attached handler also reports **crashes** to Sentry as error events, and + can be configured (via the `:capture_log_messages` and `:capture_level` keys of the + `:logs` option) to report standalone `Logger` messages as error events too, so you + do not need to add `Sentry.LoggerHandler` manually. The handler is not added if a `Sentry.LoggerHandler` is already registered. Use the `:logs` option to configure the auto-attached handler. *Available since 12.0.0*. @@ -422,7 +426,11 @@ defmodule Sentry.Config do default: [], doc: """ Configuration for the auto-attached logger handler. Only used when `:enable_logs` - is `true`. *Available since 12.0.0*. + is `true`. The `:level`, `:excluded_domains`, and `:metadata` keys configure the + **structured logs** sent to Sentry's Logs Protocol, while the `:capture_*` keys + (`:capture_log_messages`, `:capture_level`, `:capture_metadata`, and + `:capture_excluded_domains`) configure whether (and how) `Logger` messages are also + reported as **error events**. *Available since 12.0.0*. """, keys: [ level: [ @@ -440,7 +448,8 @@ defmodule Sentry.Config do default: [], type_doc: "list of `t:atom/0`", doc: """ - Domains to exclude from logs sent to Sentry's Logs Protocol. + Domains to exclude from logs sent to Sentry's Logs Protocol. This does not affect + error events; use `:capture_excluded_domains` for those. """ ], metadata: [ @@ -448,8 +457,55 @@ defmodule Sentry.Config do default: [], type_doc: "list of `t:atom/0`, or `:all`", doc: """ - Logger metadata keys to include as attributes in log events. If set to `:all`, - all metadata will be included. + Logger metadata keys to include as attributes in log events sent to Sentry's Logs + Protocol. If set to `:all`, all metadata will be included. This does not affect + error events; use `:capture_metadata` for those. + """ + ], + capture_log_messages: [ + type: :boolean, + default: false, + doc: """ + When `true`, the auto-attached handler also reports standalone log messages + (such as `Logger.error("oops")`) to Sentry as **error events**, on top of the + always-on crash reports. Messages are filtered by `:capture_level`. This mirrors + the `:capture_log_messages` option of `Sentry.LoggerHandler`. *Available since + 13.2.0*. + """ + ], + capture_level: [ + type: + {:in, + [:emergency, :alert, :critical, :error, :warning, :warn, :notice, :info, :debug]}, + default: :error, + type_doc: "`t:Logger.level/0`", + doc: """ + The minimum Logger level for messages captured as **error events** when + `:capture_log_messages` is `true`. This is independent of `:level`, which controls + the level for structured logs sent to Sentry's Logs Protocol. *Available since + 13.2.0*. + """ + ], + capture_metadata: [ + type: {:or, [{:list, :atom}, {:in, [:all]}]}, + default: [], + type_doc: "list of `t:atom/0`, or `:all`", + doc: """ + Logger metadata keys to include in **error events** captured by the auto-attached + handler, added under `:extra` as `logger_metadata`. If set to `:all`, all metadata + will be included. This is independent of `:metadata`, which controls metadata for + structured logs sent to Sentry's Logs Protocol. *Available since 13.2.0*. + """ + ], + capture_excluded_domains: [ + type: {:list, :atom}, + default: [:cowboy, :bandit], + type_doc: "list of `t:atom/0`", + doc: """ + Domains to exclude from **error events** captured by the auto-attached handler. + Defaults to `[:cowboy, :bandit]` to avoid double-reporting events already captured + by `Sentry.PlugCapture`. This is independent of `:excluded_domains`, which controls + structured logs sent to Sentry's Logs Protocol. *Available since 13.2.0*. """ ] ] @@ -1034,6 +1090,18 @@ defmodule Sentry.Config do @spec logs_metadata() :: [atom()] | :all def logs_metadata, do: Keyword.fetch!(logs(), :metadata) + @spec logs_capture_log_messages?() :: boolean() + def logs_capture_log_messages?, do: Keyword.fetch!(logs(), :capture_log_messages) + + @spec logs_capture_level() :: Logger.level() + def logs_capture_level, do: Keyword.fetch!(logs(), :capture_level) + + @spec logs_capture_metadata() :: [atom()] | :all + def logs_capture_metadata, do: Keyword.fetch!(logs(), :capture_metadata) + + @spec logs_capture_excluded_domains() :: [atom()] + def logs_capture_excluded_domains, do: Keyword.fetch!(logs(), :capture_excluded_domains) + @spec telemetry_buffer_capacities() :: %{Sentry.Telemetry.Category.t() => pos_integer()} def telemetry_buffer_capacities, do: fetch!(:telemetry_buffer_capacities) diff --git a/lib/sentry/logger_handler.ex b/lib/sentry/logger_handler.ex index 4d3b7f5f..36445381 100644 --- a/lib/sentry/logger_handler.ex +++ b/lib/sentry/logger_handler.ex @@ -119,6 +119,31 @@ defmodule Sentry.LoggerHandler do *This module is available since v9.0.0 of this library*. + This handler can do **two distinct things** with the messages it receives: + + * **Error events** — report crashes and (optionally) `Logger` messages such as + `Logger.error("oops")` to Sentry as **errors/messages**, the same way + `Sentry.capture_exception/2` and `Sentry.capture_message/2` do. This is always + active when the handler is attached. + + * **Structured logs** — forward log entries to [Sentry's Logs + UI](https://develop.sentry.dev/sdk/telemetry/logs/) as structured log events. This + is active when `:enable_logs` is `true` in your Sentry configuration. + + The two are independent: a single log can become an error event, a structured log, both, + or neither, depending on configuration. + + > #### You usually don't add this handler manually {: .tip} + > + > Setting `config :sentry, enable_logs: true` makes the SDK **automatically attach** + > this handler at startup — you do **not** need to call `:logger.add_handler/3` or + > `Logger.add_handlers/1` yourself. Configure it through the `:logs` option of your + > Sentry config (see the [Sentry configuration](Sentry.html#module-configuration) and the + > ["Sending logs to Sentry"](#module-sending-logs-to-sentry) section below). Add the + > handler manually only when you want full control over the options documented under + > ["Configuration"](#module-configuration), or when you want error reporting **without** + > structured logs. + > #### When to Use the Handler vs the Backend? {: .info} > > Sentry's Elixir SDK also ships with `Sentry.LoggerBackend`, an Elixir `Logger` @@ -205,6 +230,56 @@ defmodule Sentry.LoggerHandler do # ... end + ## Sending logs to Sentry + + To send structured logs to [Sentry's Logs UI](https://develop.sentry.dev/sdk/telemetry/logs/), + enable logs in your Sentry configuration. This auto-attaches the handler — there is + **no need** to configure `:logger` or call `:logger.add_handler/3`: + + config :sentry, + # ... + enable_logs: true, + logs: [level: :info, metadata: [:request_id]] + + With this configuration, every `Logger` call at `:info` or above becomes a structured log + event in Sentry, and crashes are still reported as **error events** (just like the manual + setup above). The `:logs` options are documented in the + [Sentry configuration](Sentry.html#module-configuration). + + ### Also capturing `Logger` messages as error events + + By default the auto-attached handler reports **crashes** as error events but leaves + standalone messages (such as `Logger.error("oops")`) as structured logs only. To also + report those messages as error events — for example, to turn `Logger.error/1` calls into + Sentry issues while keeping `Logger.info/1` out of your issues stream — use the `:capture_*` + keys under `:logs`: + + config :sentry, + enable_logs: true, + logs: [ + level: :info, # structured logs at :info and above -> Logs UI + capture_log_messages: true, # also report messages as error events... + capture_level: :error, # ...but only at :error and above + capture_metadata: :all, # include Logger metadata in those error events + capture_excluded_domains: [:cowboy] # domains to exclude from those error events + ] + + The `:capture_*` keys configure the **error-event** side and are independent from the + matching Logs-UI keys (`:metadata`/`:capture_metadata`, + `:excluded_domains`/`:capture_excluded_domains`). + + > #### Including `Logger` metadata {: .info} + > + > For the auto-attached handler, metadata for the two destinations is configured + > separately. The `:metadata` key **under `:logs`** lists the `Logger` metadata attached + > as attributes on **structured logs** (shown in the Logs UI). The `:capture_metadata` + > key lists the metadata attached under `:extra` (as `logger_metadata`) on **error + > events**. Both accept a list of keys or `:all`, and both default to `[]`. So if your + > custom metadata is missing from a captured *error event*, set + > `config :sentry, logs: [capture_metadata: [...]]` (or `:all`). When you add the handler + > manually instead, use this module's own `:metadata` + > [configuration option](#module-configuration). + ## Configuration This handler supports the following configuration options: diff --git a/test/sentry/application_test.exs b/test/sentry/application_test.exs index bc49c3e8..68f40be8 100644 --- a/test/sentry/application_test.exs +++ b/test/sentry/application_test.exs @@ -20,6 +20,23 @@ defmodule Sentry.ApplicationTest do assert Sentry.Config.logs_level() == :info assert Sentry.Config.logs_excluded_domains() == [] assert Sentry.Config.logs_metadata() == [] + + assert config.config.capture_log_messages == false + assert config.config.level == :error + assert config.config.metadata == [] + assert config.config.excluded_domains == [:cowboy, :bandit] + end + + test "respects logs.capture_log_messages and logs.capture_level config" do + restart_sentry_with( + dsn: "https://public@sentry.example.com/1", + enable_logs: true, + logs: [capture_log_messages: true, capture_level: :warning] + ) + + assert {:ok, config} = :logger.get_handler_config(:sentry_log_handler) + assert config.config.capture_log_messages == true + assert config.config.level == :warning end test "respects logs.level config" do @@ -40,8 +57,22 @@ defmodule Sentry.ApplicationTest do logs: [excluded_domains: [:cowboy, :ranch]] ) - assert {:ok, _config} = :logger.get_handler_config(:sentry_log_handler) + assert {:ok, config} = :logger.get_handler_config(:sentry_log_handler) assert Sentry.Config.logs_excluded_domains() == [:cowboy, :ranch] + # :excluded_domains is for the Logs UI only; error-event exclusions are governed by + # the separate :capture_excluded_domains option (defaults to [:cowboy, :bandit]). + assert config.config.excluded_domains == [:cowboy, :bandit] + end + + test "respects logs.capture_excluded_domains config" do + restart_sentry_with( + dsn: "https://public@sentry.example.com/1", + enable_logs: true, + logs: [capture_excluded_domains: [:cowboy, :ranch]] + ) + + assert {:ok, config} = :logger.get_handler_config(:sentry_log_handler) + assert config.config.excluded_domains == [:cowboy, :ranch] end test "respects logs.metadata config" do @@ -51,8 +82,22 @@ defmodule Sentry.ApplicationTest do logs: [metadata: [:request_id, :user_id]] ) - assert {:ok, _config} = :logger.get_handler_config(:sentry_log_handler) + assert {:ok, config} = :logger.get_handler_config(:sentry_log_handler) assert Sentry.Config.logs_metadata() == [:request_id, :user_id] + # :metadata is for the Logs UI only; it must not leak into error-event metadata, + # which is governed by the separate :capture_metadata option. + assert config.config.metadata == [] + end + + test "respects logs.capture_metadata config" do + restart_sentry_with( + dsn: "https://public@sentry.example.com/1", + enable_logs: true, + logs: [capture_metadata: [:request_id, :user_id]] + ) + + assert {:ok, config} = :logger.get_handler_config(:sentry_log_handler) + assert config.config.metadata == [:request_id, :user_id] end test "does not attach handler when enable_logs is false" do diff --git a/test/sentry/config_test.exs b/test/sentry/config_test.exs index b0780868..fe3baada 100644 --- a/test/sentry/config_test.exs +++ b/test/sentry/config_test.exs @@ -83,6 +83,37 @@ defmodule Sentry.ConfigTest do end end + test ":logs capture options" do + defaults = Config.validate!([])[:logs] + assert defaults[:capture_log_messages] == false + assert defaults[:capture_level] == :error + assert defaults[:capture_metadata] == [] + assert defaults[:capture_excluded_domains] == [:cowboy, :bandit] + + configured = + Config.validate!( + logs: [ + capture_log_messages: true, + capture_level: :warning, + capture_metadata: :all, + capture_excluded_domains: [:cowboy, :ranch] + ] + )[:logs] + + assert configured[:capture_log_messages] == true + assert configured[:capture_level] == :warning + assert configured[:capture_metadata] == :all + assert configured[:capture_excluded_domains] == [:cowboy, :ranch] + + assert_raise ArgumentError, ~r/invalid value for :capture_level option/, fn -> + Config.validate!(logs: [capture_level: :invalid]) + end + + assert_raise ArgumentError, ~r/invalid value for :capture_metadata option/, fn -> + Config.validate!(logs: [capture_metadata: "all"]) + end + end + test ":source_code_path_pattern" do assert Config.validate!(source_code_path_pattern: "*.ex")[:source_code_path_pattern] == "*.ex" diff --git a/test/sentry/logger_handler/logs_test.exs b/test/sentry/logger_handler/logs_test.exs index 0b28173d..a990020a 100644 --- a/test/sentry/logger_handler/logs_test.exs +++ b/test/sentry/logger_handler/logs_test.exs @@ -224,6 +224,138 @@ defmodule Sentry.LoggerHandler.LogsTest do end end + describe "capturing Logger messages as error events (logs.capture_log_messages)" do + setup %{handler_name: handler_name} do + :ok = :logger.remove_handler(handler_name) + + put_test_config( + logs: [ + level: :info, + metadata: :all, + capture_log_messages: true, + capture_level: :error, + capture_metadata: :all + ] + ) + + name = :"sentry_capture_handler_#{System.unique_integer([:positive])}" + + handler_config = %{ + level: Sentry.Config.logs_capture_level(), + capture_log_messages: Sentry.Config.logs_capture_log_messages?(), + metadata: Sentry.Config.logs_capture_metadata() + } + + assert :ok = :logger.add_handler(name, Sentry.LoggerHandler, %{config: handler_config}) + + on_exit(fn -> _ = :logger.remove_handler(name) end) + + %{handler_name: name} + end + + test "Logger.error is sent as both an error event and a structured log" do + Logger.error("boom from logger") + + assert_sentry_report(:event, message: %{formatted: "boom from logger"}) + assert_sentry_log(:error, "boom from logger") + end + + test "messages below :capture_level are sent as logs but not as error events" do + Logger.info("just an info line") + Logger.warning("a warning line") + + assert_sentry_log(:info, "just an info line") + assert_sentry_log(:warn, "a warning line") + + assert SentryTest.pop_sentry_reports() == [] + end + + test "structured log keyword data is reported as an error event too" do + Logger.error(some: "structured", value: 42) + + event = assert_sentry_report(:event, []) + assert event.message.formatted =~ "structured" + end + + test "includes custom Logger metadata in the captured error event" do + Logger.error("Hello Buggy Bug", some_info: "boom!") + + event = assert_sentry_report(:event, message: %{formatted: "Hello Buggy Bug"}) + assert event.extra.logger_metadata.some_info == "boom!" + end + + test "logs.metadata feeds the Logs UI but not error events (capture_metadata governs that)", + %{handler_name: handler_name} do + :ok = :logger.remove_handler(handler_name) + + # Metadata is configured for the Logs UI, but capture_metadata is left at its + # default ([]), so error events must not include the metadata. + put_test_config( + logs: [ + level: :info, + metadata: :all, + capture_log_messages: true, + capture_level: :error, + capture_metadata: [] + ] + ) + + name = :"sentry_no_capture_meta_#{System.unique_integer([:positive])}" + + handler_config = %{ + level: Sentry.Config.logs_capture_level(), + capture_log_messages: Sentry.Config.logs_capture_log_messages?(), + metadata: Sentry.Config.logs_capture_metadata() + } + + assert :ok = :logger.add_handler(name, Sentry.LoggerHandler, %{config: handler_config}) + on_exit(fn -> _ = :logger.remove_handler(name) end) + + Logger.error("no meta in event", secret_info: "hidden") + + event = assert_sentry_report(:event, message: %{formatted: "no meta in event"}) + assert event.extra.logger_metadata == %{} + + # The structured log still carries the metadata, since :metadata is :all. + log = assert_sentry_log(:error, "no meta in event") + assert log.attributes[:secret_info] == "hidden" + end + + test "capture_excluded_domains drops error events but keeps the structured log", + %{handler_name: handler_name} do + :ok = :logger.remove_handler(handler_name) + + # The domain is excluded from error events but not from the Logs UI. + put_test_config( + logs: [ + level: :info, + excluded_domains: [], + capture_log_messages: true, + capture_level: :error, + capture_excluded_domains: [:myapp] + ] + ) + + name = :"sentry_excluded_domain_#{System.unique_integer([:positive])}" + + handler_config = %{ + level: Sentry.Config.logs_capture_level(), + capture_log_messages: Sentry.Config.logs_capture_log_messages?(), + excluded_domains: Sentry.Config.logs_capture_excluded_domains() + } + + assert :ok = :logger.add_handler(name, Sentry.LoggerHandler, %{config: handler_config}) + on_exit(fn -> _ = :logger.remove_handler(name) end) + + Logger.error("error from excluded domain", domain: [:myapp]) + + # The structured log is still captured (Logs UI :excluded_domains is []). + assert_sentry_log(:error, "error from excluded domain") + # But no error event, because the domain is in :capture_excluded_domains. + assert SentryTest.pop_sentry_reports() == [] + end + end + describe "OpenTelemetry integration with opentelemetry_logger_metadata" do setup do :ok = OpentelemetryLoggerMetadata.setup()