Repository: phoenixframework/phoenix
Pull request #3928 (2020-07-10 22:09:20)

Add additional example of using tag_values in Telemetry guide
I'm finding `:tag_values` is really powerful when controlling how metrics show up in LiveDashboard, so I thought it might be worth adding another example. This also serves to promote the new `Phoenix.LiveView` telemetry events.


Patch:
diff --git a/guides/telemetry.md b/guides/telemetry.md
index e5027ac62..59e97d825 100644
--- a/guides/telemetry.md
+++ b/guides/telemetry.md
@@ -397,6 +397,61 @@ route.
 Note the `:tags` and `:tag_values` options can be applied to
 all `Telemetry.Metrics` types.
 
+### Renaming value labels using tag values
+
+Sometimes when displaying a metric, the value label may need to be transformed
+to improve readability. Take for example the following metric that displays the
+duration of the each LiveView's `mount/3` callback by `connected?` status.
+
+```elixir
+summary("phoenix.live_view.mount.stop.duration",
+  unit: {:native, :millisecond},
+  tags: [:view, :connected?],
+  tag_values: &live_view_metric_tag_values/1
+)
+```
+
+The following function lifts `metadata.socket.view` and
+`metadata.socket.connected?` to be top-level keys on `metadata`, as we did in
+the previous example.
+
+```elixir
+# lib/my_app_web/telemetry.ex
+defp live_view_metric_tag_values(metadata) do
+  metadata
+  |> Map.put(:view, metadata.socket.view)
+  |> Map.put(:connected?, metadata.socket.connected?)
+end
+```
+
+However, when rendering these metrics in LiveDashboard, the value label is
+output as `"Elixir.Phoenix.LiveDashboard.MetricsLive true"`.
+
+To make the value label easier to read, we can update our private function to
+generate more user friendly names. We'll run the value of the `:view` through
+`inspect/1` to remove the `Elixir.` prefix and call another private function to
+convert the `connected?` boolean into human readable text.
+
+```elixir
+# lib/my_app_web/telemetry.ex
+defp live_view_metric_tag_values(metadata) do
+  metadata
+  |> Map.put(:view, inspect(metadata.socket.view))
+  |> Map.put(:connected?, get_connection_status(metadata.socket))
+end
+
+defp get_connection_status(%{connected?: true}), do: "Connected"
+defp get_connection_status(%{connected?: false}), do: "Disconnected"
+```
+
+Now the value label will be rendered like `"Phoenix.LiveDashboard.MetricsLive
+Connected"`.
+
+Hopefully, this gives you some inspiration on how to use the `:tag_values`
+option. Just remember to keep this function fast since it is called on every
+event.
+
+
 ## Periodic measurements
 
 You might want to periodically measure key values within
diff --git a/guides/views.md b/guides/views.md
index 14023537f..119d6f857 100644
--- a/guides/views.md
+++ b/guides/views.md
@@ -107,7 +107,7 @@ Pretty neat, right? At compile-time, Phoenix precompiles all `*.html.eex` templa
 
 ### Manually rendering templates
 
-So far, Phoenix has taken care of putting every in place and rendering views for us. However, we can also render views directly.
+So far, Phoenix has taken care of putting everything in place and rendering views for us. However, we can also render views directly.
 
 Let's create a new template to play around with, `lib/hello_web/templates/page/test.html.eex`:
 
diff --git a/lib/mix/tasks/phx.digest.ex b/lib/mix/tasks/phx.digest.ex
index 9b5ae270a..970d2b9d8 100644
--- a/lib/mix/tasks/phx.digest.ex
+++ b/lib/mix/tasks/phx.digest.ex
@@ -37,6 +37,7 @@ defmodule Mix.Tasks.Phx.Digest do
 
   @doc false
   def run(all_args) do
+    Mix.Task.run "compile", all_args
     {opts, args, _} = OptionParser.parse(all_args, switches: [output: :string], aliases: [o: :output])
     input_path = List.first(args) || @default_input_path
     output_path = opts[:output] || input_path
diff --git a/lib/phoenix/digester.ex b/lib/phoenix/digester.ex
index 26c898e7d..559bcb73b 100644
--- a/lib/phoenix/digester.ex
+++ b/lib/phoenix/digester.ex
@@ -118,8 +118,11 @@ defmodule Phoenix.Digester do
   defp manifest_join(path, filename), do: Path.join(path, filename)
 
   defp compiled_file?(file_path) do
+    compressors = Application.fetch_env!(:phoenix, :static_compressors)
+    compressed_extensions = Enum.flat_map(compressors, &(&1.file_extensions))
+
     Regex.match?(@digested_file_regex, Path.basename(file_path)) ||
-      Path.extname(file_path) in ~w(.gz .br) ||
+      Path.extname(file_path) in compressed_extensions ||
       Path.basename(file_path) == "cache_manifest.json"
   end
 
@@ -148,18 +151,25 @@ defmodule Phoenix.Digester do
     path = Path.join(output_path, file.relative_path)
     File.mkdir_p!(path)
 
-    # compressed files
-    if compress_file?(file) do
-      File.write!(
-        Path.join(path, file.digested_filename <> ".gz"),
-        :zlib.gzip(file.digested_content)
-      )
-
-      File.write!(
-        Path.join(path, file.filename <> ".gz"),
-        :zlib.gzip(file.content)
-      )
-    end
+    compressors = Application.fetch_env!(:phoenix, :static_compressors)
+
+    Enum.each(compressors, fn(compressor) ->
+      [file_extension | _] = compressor.file_extensions
+
+      with {:ok, compressed_digested} <- compressor.compress_file(file.digested_filename, file.digested_content) do
+        File.write!(
+          Path.join(path, file.digested_filename <> file_extension),
+          compressed_digested
+        )
+      end
+
+      with {:ok, compressed} <- compressor.compress_file(file.filename, file.content) do
+        File.write!(
+          Path.join(path, file.filename <> file_extension),
+          compressed
+        )
+      end
+    end)
 
     # uncompressed files
     File.write!(Path.join(path, file.digested_filename), file.digested_content)
@@ -168,10 +178,6 @@ defmodule Phoenix.Digester do
     file
   end
 
-  defp compress_file?(file) do
-    Path.extname(file.filename) in Application.fetch_env!(:phoenix, :gzippable_exts)
-  end
-
   defp digested_contents(file, latest) do
     ext = Path.extname(file.filename)
 
diff --git a/lib/phoenix/digester/compressor.ex b/lib/phoenix/digester/compressor.ex
new file mode 100644
index 000000000..d93c24a28
--- /dev/null
+++ b/lib/phoenix/digester/compressor.ex
@@ -0,0 +1,44 @@
+defmodule Phoenix.Digester.Compressor do
+  @moduledoc ~S"""
+  Defines the `Phoenix.Digester.Compressor` behaviour for
+  implementing static file compressors.
+
+  A custom compressor expects 2 functions to be implemented.
+
+  By default, Phoenix uses only `Phoenix.Digester.Gzip` to compress
+  static files, but additional compressors can be defined and added
+  to the digest process.
+
+  ## Example
+
+  If you wanted to compress files using an external brotli compression
+  library, you could define a new module implementing the behaviour and add the
+  module to the list of configured Phoenix static compressors.
+
+      defmodule MyApp.BrotliCompressor do
+        @behaviour Phoenix.Digester.Compressor
+
+        def compress_file(file_path, content) do
+          valid_extension = Path.extname(file_path) in Application.fetch_env!(:phoenix, :gzippable_exts)
+          compressed_content = :brotli.encode(content)
+
+          if valid_extension && byte_size(compressed_content) < byte_size(content) do
+            {:ok, compressed_content}
+          else
+            :error
+          end
+        end
+
+        def file_extensions do
+          [".br"]
+        end
+      end
+
+      # config/config.exs
+      config :phoenix,
+        static_compressors: [Phoenix.Digester.Gzip, MyApp.BrotliCompressor],
+        # ...
+  """
+  @callback compress_file(Path.t(), binary()) :: {:ok, binary()} | :error
+  @callback file_extensions() :: nonempty_list(String.t())
+end
diff --git a/lib/phoenix/digester/gzip.ex b/lib/phoenix/digester/gzip.ex
new file mode 100644
index 000000000..6f86b4638
--- /dev/null
+++ b/lib/phoenix/digester/gzip.ex
@@ -0,0 +1,18 @@
+defmodule Phoenix.Digester.Gzip do
+  @moduledoc ~S"""
+  Gzip compressor for Phoenix.Digester
+  """
+  @behaviour Phoenix.Digester.Compressor
+
+  def compress_file(file_path, content) do
+    if Path.extname(file_path) in Application.fetch_env!(:phoenix, :gzippable_exts) do
+      {:ok, :zlib.gzip(content)}
+    else
+      :error
+    end
+  end
+
+  def file_extensions do
+    [".gz"]
+  end
+end
diff --git a/mix.exs b/mix.exs
index 64508d104..5c09b3468 100644
--- a/mix.exs
+++ b/mix.exs
@@ -55,6 +55,7 @@ defmodule Phoenix.MixProject do
         filter_parameters: ["password"],
         serve_endpoints: false,
         gzippable_exts: ~w(.js .css .txt .text .html .json .svg .eot .ttf),
+        static_compressors: [Phoenix.Digester.Gzip],
         trim_on_html_eex_engine: true
       ]
     ]


Test patch:
diff --git a/test/phoenix/digester/gzip_test.exs b/test/phoenix/digester/gzip_test.exs
new file mode 100644
index 000000000..76c08e1eb
--- /dev/null
+++ b/test/phoenix/digester/gzip_test.exs
@@ -0,0 +1,13 @@
+defmodule Phoenix.Digester.GzipTest do
+  use ExUnit.Case, async: true
+  alias Phoenix.Digester.Gzip
+
+  test "compress_file/2 compresses file" do
+    file_path = "test/fixtures/digest/priv/static/css/app.css"
+    content = File.read!(file_path)
+
+    {:ok, compressed} = Gzip.compress_file(file_path, content)
+
+    assert is_binary(compressed)
+  end
+end
diff --git a/test/phoenix/digester_test.exs b/test/phoenix/digester_test.exs
index 37df1b8d6..12cccaf8b 100644
--- a/test/phoenix/digester_test.exs
+++ b/test/phoenix/digester_test.exs
@@ -91,7 +91,6 @@ defmodule Phoenix.DigesterTest do
 
       json = Path.join(@output_path, "cache_manifest.json") |> json_read!()
       refute json["latest"]["precompressed.js.gz"]
-      refute json["latest"]["precompressed.js.br"]
 
       assert :ok = Phoenix.Digester.compile(@output_path, @output_path)
       assert output_files == assets_files(@output_path)