├── .formatter.exs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── opentelemetry_liveview.ex └── opentelemetry_liveview │ └── reason.ex ├── mix.exs ├── mix.lock └── test ├── opentelemetry_liveview_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 100, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | name: OTP ${{matrix.otp_version}} / Elixir ${{matrix.elixir}} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | otp_version: ['25', '24', '23'] 19 | elixir: ['1.14.0', '1.13.4', '1.12.3', '1.11.4'] 20 | exclude: 21 | - otp_version: '25' 22 | elixir: '1.11.4' 23 | - otp_version: '25' 24 | elixir: '1.12.3' 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: erlef/setup-elixir@v1 28 | with: 29 | otp-version: ${{matrix.otp_version}} 30 | elixir-version: ${{matrix.elixir}} 31 | 32 | - name: Elixir cache 33 | uses: actions/cache@v2 34 | id: elixir-build-cache 35 | with: 36 | path: | 37 | _build 38 | deps 39 | key: ${{ runner.os }}-${{ matrix.otp_version }}-${{ matrix.elixir }}-${{ env.MIX_ENV }}-elixir_cache-mixlockhash-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 40 | restore-keys: | 41 | ${{ runner.os }}-${{ matrix.otp_version }}-${{ matrix.elixir }}-${{ env.MIX_ENV }}-elixir_cache- 42 | 43 | - name: Mix dependencies 44 | if: steps.elixir-build-cache.outputs.cache-hit != 'true' 45 | run: mix deps.get 46 | 47 | - name: Compile dependencies 48 | if: steps.elixir-build-cache.outputs.cache-hit != 'true' 49 | run: mix deps.compile 50 | 51 | - name: Compile 52 | run: mix compile --warnings-as-errors --force 53 | 54 | - name: Check formatting 55 | run: mix format --check-formatted 56 | 57 | - name: Tests 58 | run: mix test 59 | 60 | - name: Dialyzer 61 | run: mix dialyzer 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | opentelemetry_liveview-*.tar 24 | 25 | test/support/deps/ 26 | test/support/_build/ 27 | 28 | priv 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0-rc.4 4 | 5 | * Fix handlilng exceptions coming from live components #10 6 | 7 | ## 1.0.0-rc.3 8 | 9 | * Support Telemetry 1.0 10 | 11 | ## 1.0.0-rc.2 12 | 13 | * Support Telemetry 1.0.0-rc 14 | 15 | ## 1.0.0-rc.1 16 | 17 | * Initial release 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpentelemetryLiveView 2 | ![Build Status](https://github.com/qdentity/opentelemetry_liveview/workflows/Tests/badge.svg) 3 | 4 | Telemetry handler that creates Opentelemetry spans from Phoenix LiveView events. 5 | 6 | After installing, setup the handler in your application behaviour before your 7 | top-level supervisor starts. 8 | 9 | ```elixir 10 | OpentelemetryLiveView.setup() 11 | ``` 12 | 13 | ## Installation 14 | 15 | ```elixir 16 | def deps do 17 | [ 18 | {:opentelemetry_liveview, "~> 1.0.0-rc"} 19 | ] 20 | end 21 | ``` 22 | 23 | ## Acknowledgements 24 | 25 | See https://github.com/opentelemetry-beam/opentelemetry_phoenix for tracing Phoenix web requests. 26 | The code and tests in this repository are based on that library. 27 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :test do 4 | config :opentelemetry, 5 | traces_exporter: :none, 6 | processors: [{:otel_batch_processor, %{scheduled_delay_ms: 1}}] 7 | end 8 | -------------------------------------------------------------------------------- /lib/opentelemetry_liveview.ex: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryLiveView do 2 | @moduledoc """ 3 | OpentelemetryLiveView uses [telemetry](https://hexdocs.pm/telemetry/) handlers to create 4 | `OpenTelemetry` spans for LiveView *mount*, *handle_params*, and *handle_event*. The LiveView 5 | telemetry events that are used are documented [here](https://hexdocs.pm/phoenix_live_view/telemetry.html). 6 | 7 | ## Usage 8 | 9 | Add in your application start function a call to `setup/0`: 10 | 11 | def start(_type, _args) do 12 | # this configures the liveview tracing 13 | OpentelemetryLiveView.setup() 14 | 15 | children = [ 16 | ... 17 | ] 18 | 19 | ... 20 | end 21 | 22 | """ 23 | 24 | require OpenTelemetry.Tracer 25 | alias OpenTelemetry.Span 26 | alias OpentelemetryLiveView.Reason 27 | 28 | @tracer_id __MODULE__ 29 | 30 | @event_names [ 31 | {:live_view, :mount}, 32 | {:live_view, :handle_params}, 33 | {:live_view, :handle_event}, 34 | {:live_component, :handle_event} 35 | ] 36 | |> Enum.flat_map(fn {kind, callback_name} -> 37 | Enum.map([:start, :stop, :exception], fn event_name -> 38 | [:phoenix, kind, callback_name, event_name] 39 | end) 40 | end) 41 | 42 | @doc """ 43 | Initializes and configures the telemetry handlers. 44 | """ 45 | @spec setup() :: :ok 46 | def setup do 47 | :telemetry.attach_many(__MODULE__, @event_names, &__MODULE__.process_event/4, %{}) 48 | end 49 | 50 | defguardp live_view_or_component?(source) when source in [:live_view, :live_component] 51 | 52 | @doc false 53 | def process_event([:phoenix, source, callback_name, :start], _measurements, meta, _config) 54 | when live_view_or_component?(source) do 55 | module = 56 | case {source, meta} do 57 | {:live_view, _} -> module_to_string(meta.socket.view) 58 | {:live_component, %{component: component}} -> module_to_string(component) 59 | end 60 | 61 | base_attributes = [ 62 | "liveview.module": module, 63 | "liveview.callback": Atom.to_string(callback_name) 64 | ] 65 | 66 | attributes = 67 | Enum.reduce(meta, base_attributes, fn 68 | {:uri, uri}, acc -> 69 | Keyword.put(acc, :"liveview.uri", uri) 70 | 71 | {:component, component}, acc -> 72 | Keyword.put(acc, :"liveview.module", module_to_string(component)) 73 | 74 | {:event, event}, acc -> 75 | Keyword.put(acc, :"liveview.event", event) 76 | 77 | _, acc -> 78 | acc 79 | end) 80 | 81 | span_name = 82 | case Keyword.fetch(attributes, :"liveview.event") do 83 | {:ok, event} -> "#{module}.#{event}" 84 | :error -> "#{module}.#{callback_name}" 85 | end 86 | 87 | OpentelemetryTelemetry.start_telemetry_span(@tracer_id, span_name, meta, %{kind: :internal}) 88 | |> Span.set_attributes(attributes) 89 | end 90 | 91 | @doc false 92 | def process_event([:phoenix, source, _kind, :stop], %{duration: duration}, meta, _config) 93 | when live_view_or_component?(source) do 94 | ctx = OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, meta) 95 | 96 | set_duration(ctx, duration) 97 | 98 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, meta) 99 | end 100 | 101 | @doc false 102 | def process_event( 103 | [:phoenix, source, _kind, :exception], 104 | %{duration: duration}, 105 | %{kind: exception_kind, reason: reason, stacktrace: stacktrace} = meta, 106 | _config 107 | ) 108 | when live_view_or_component?(source) do 109 | ctx = OpentelemetryTelemetry.set_current_telemetry_span(@tracer_id, meta) 110 | 111 | set_duration(ctx, duration) 112 | 113 | {[reason: reason], attrs} = Reason.normalize(reason) |> Keyword.split([:reason]) 114 | 115 | exception = Exception.normalize(exception_kind, reason, stacktrace) 116 | message = Exception.message(exception) 117 | 118 | Span.record_exception(ctx, exception, stacktrace, attrs) 119 | Span.set_status(ctx, OpenTelemetry.status(:error, message)) 120 | 121 | OpentelemetryTelemetry.end_telemetry_span(@tracer_id, meta) 122 | end 123 | 124 | defp set_duration(ctx, duration) do 125 | duration_ms = System.convert_time_unit(duration, :native, :millisecond) 126 | Span.set_attribute(ctx, :duration_ms, duration_ms) 127 | end 128 | 129 | defp module_to_string(module) when is_atom(module) do 130 | case to_string(module) do 131 | "Elixir." <> name -> name 132 | erlang_module -> ":#{erlang_module}" 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/opentelemetry_liveview/reason.ex: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryLiveView.Reason do 2 | @moduledoc false 3 | 4 | def normalize(%{reason: reason}) do 5 | normalize(reason) 6 | end 7 | 8 | def normalize(:badarg) do 9 | [reason: :badarg] 10 | end 11 | 12 | def normalize(:badarith) do 13 | [reason: :badarith] 14 | end 15 | 16 | def normalize(:system_limit) do 17 | [reason: :system_limit] 18 | end 19 | 20 | def normalize(:cond_clause) do 21 | [reason: :cond_clause] 22 | end 23 | 24 | def normalize(:undef) do 25 | [reason: :undef] 26 | end 27 | 28 | def normalize({:badarity, {fun, args}}) do 29 | {:arity, arity} = Function.info(fun, :arity) 30 | [reason: :badarity, function: "#{inspect(fun)}", arity: arity, args: args] 31 | end 32 | 33 | def normalize({:badfun, term}) do 34 | [reason: :badfun, term: term] 35 | end 36 | 37 | def normalize({:badstruct, struct, term}) do 38 | [reason: :badstruct, struct: struct, term: term] 39 | end 40 | 41 | def normalize({:badmatch, term}) do 42 | [reason: :badmatch, term: term] 43 | end 44 | 45 | def normalize({:badmap, term}) do 46 | [reason: :badmap, term: term] 47 | end 48 | 49 | def normalize({:badbool, op, term}) do 50 | [reason: :badbool, operator: op, term: term] 51 | end 52 | 53 | def normalize({:badkey, key}) do 54 | [reason: :badkey, key: key] 55 | end 56 | 57 | def normalize({:badkey, key, map}) do 58 | [reason: :badkey, key: key, map: map] 59 | end 60 | 61 | def normalize({:case_clause, term}) do 62 | [reason: :case_clause, term: term] 63 | end 64 | 65 | def normalize({:with_clause, term}) do 66 | [reason: :with_clause, term: term] 67 | end 68 | 69 | def normalize({:try_clause, term}) do 70 | [reason: :try_clause, term: term] 71 | end 72 | 73 | def normalize({:badarg, payload}) do 74 | [reason: :badarg, payload: payload] 75 | end 76 | 77 | def normalize(other) do 78 | [reason: other] 79 | end 80 | 81 | def normalize(other, _stacktrace) do 82 | [reason: other] 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryLiveView.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :opentelemetry_liveview, 7 | description: description(), 8 | version: "1.0.0-rc.4", 9 | elixir: "~> 1.10", 10 | start_permanent: Mix.env() == :prod, 11 | dialyzer: [plt_core_path: if(System.get_env("CI") == "true", do: "_build/#{Mix.env()}")], 12 | deps: deps(), 13 | name: "Opentelemetry LiveView", 14 | docs: [ 15 | main: "OpentelemetryLiveView", 16 | extras: ["README.md"] 17 | ], 18 | elixirc_paths: elixirc_paths(Mix.env()), 19 | package: package(), 20 | source_url: "https://github.com/qdentity/opentelemetry_liveview" 21 | ] 22 | end 23 | 24 | # Run "mix help compile.app" to learn about applications. 25 | def application do 26 | [ 27 | extra_applications: [] 28 | ] 29 | end 30 | 31 | defp description do 32 | "Trace Phoenix LiveView requests with OpenTelemetry." 33 | end 34 | 35 | defp package do 36 | [ 37 | description: "OpenTelemetry tracing for Phoenix LiveView", 38 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*), 39 | licenses: ["Apache-2.0"], 40 | links: %{ 41 | "GitHub" => "https://github.com/qdentity/opentelemetry_liveview", 42 | "OpenTelemetry Phoenix" => "https://github.com/opentelemetry-beam/opentelemetry_phoenix", 43 | "OpenTelemetry Erlang" => "https://github.com/open-telemetry/opentelemetry-erlang", 44 | "OpenTelemetry.io" => "https://opentelemetry.io" 45 | } 46 | ] 47 | end 48 | 49 | defp elixirc_paths(:test), do: ["lib", "test/support"] 50 | defp elixirc_paths(_), do: ["lib"] 51 | 52 | # Run "mix help deps" to learn about dependencies. 53 | defp deps do 54 | [ 55 | {:opentelemetry_api, "~> 1.0"}, 56 | {:opentelemetry, "~> 1.0", only: [:test]}, 57 | {:opentelemetry_telemetry, "~> 1.0"}, 58 | {:telemetry, "~> 0.4 or ~> 1.0"}, 59 | {:ex_doc, "~> 0.24", only: [:dev], runtime: false}, 60 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} 61 | ] 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, 4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 5 | "ex_doc": {:hex, :ex_doc, "0.28.5", "3e52a6d2130ce74d096859e477b97080c156d0926701c13870a4e1f752363279", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d2c4b07133113e9aa3e9ba27efb9088ba900e9e51caa383919676afdf09ab181"}, 6 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 10 | "opentelemetry": {:hex, :opentelemetry, "1.1.1", "02de53d7dcafc087793ddf98cac946aaaa13c99cb6a7e568d9bb5ce4552b340e", [:rebar3], [{:opentelemetry_api, "~> 1.1", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "43a807d536bca55542731ddb5ecf68c0b3b433ff98713a6496058075bac70031"}, 11 | "opentelemetry_api": {:hex, :opentelemetry_api, "1.1.0", "156366bfbf249f54daf2626e087e29ad91201eab670993fd9ae1bd278d03a096", [:mix, :rebar3], [], "hexpm", "e0d0b49e21e5785da675c97104c385283cae84fcc0d8522932a5dcf55489ead1"}, 12 | "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.0.0", "d5982a319e725fcd2305b306b65c18a86afdcf7d96821473cf0649ff88877615", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.0", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "3401d13a1d4b7aa941a77e6b3ec074f0ae77f83b5b2206766ce630123a9291a9"}, 13 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 14 | "telemetry_registry": {:hex, :telemetry_registry, "0.3.0", "6768f151ea53fc0fbca70dbff5b20a8d663ee4e0c0b2ae589590e08658e76f1e", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "492e2adbc609f3e79ece7f29fec363a97a2c484ac78a83098535d6564781e917"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/opentelemetry_liveview_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OpentelemetryLiveViewTest do 2 | use ExUnit.Case, async: false 3 | 4 | # require OpenTelemetry.Tracer 5 | # require OpenTelemetry.Span 6 | require Record 7 | 8 | # alias PhoenixMeta, as: Meta 9 | 10 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do 11 | Record.defrecord(name, spec) 12 | end 13 | 14 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry_api/include/opentelemetry.hrl") do 15 | Record.defrecord(name, spec) 16 | end 17 | 18 | setup do 19 | :application.stop(:opentelemetry) 20 | :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) 21 | 22 | :application.set_env(:opentelemetry, :processors, [ 23 | {:otel_batch_processor, %{scheduled_delay_ms: 1}} 24 | ]) 25 | 26 | :application.start(:opentelemetry) 27 | 28 | :otel_batch_processor.set_exporter(:otel_exporter_pid, self()) 29 | 30 | OpentelemetryLiveView.setup() 31 | 32 | :ok 33 | end 34 | 35 | @meta %{socket: %{view: SomeWeb.SomeLive}} 36 | 37 | @bad_key_error %{ 38 | kind: :error, 39 | reason: {:badkey, :name, %{username: "foobar"}}, 40 | stacktrace: [ 41 | {MyStore.Users, :sort_by_name, 2, [file: 'lib/my_store/users.ex', line: 159]}, 42 | {Enum, :"-to_sort_fun/1-fun-0-", 3, [file: 'lib/enum.ex', line: 2542]}, 43 | {:lists, :sort, 2, [file: 'lists.erl', line: 969]} 44 | ] 45 | } 46 | 47 | test "records spans for the mount callback" do 48 | :telemetry.execute( 49 | [:phoenix, :live_view, :mount, :start], 50 | %{system_time: System.system_time()}, 51 | @meta 52 | ) 53 | 54 | :telemetry.execute( 55 | [:phoenix, :live_view, :mount, :stop], 56 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 57 | @meta 58 | ) 59 | 60 | assert_receive {:span, 61 | span( 62 | name: "SomeWeb.SomeLive.mount", 63 | kind: :internal, 64 | attributes: attributes 65 | ) = span} 66 | 67 | assert :otel_attributes.map(attributes) == %{ 68 | duration_ms: 42, 69 | "liveview.callback": "mount", 70 | "liveview.module": "SomeWeb.SomeLive" 71 | } 72 | 73 | assert_instrumentation_scope(span) 74 | end 75 | 76 | test "records exceptions for the mount callback" do 77 | :telemetry.execute( 78 | [:phoenix, :live_view, :mount, :start], 79 | %{system_time: System.system_time()}, 80 | @meta 81 | ) 82 | 83 | :telemetry.execute( 84 | [:phoenix, :live_view, :mount, :exception], 85 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 86 | Map.merge(@meta, @bad_key_error) 87 | ) 88 | 89 | {span, attributes} = assert_receive_bad_key_error_span("SomeWeb.SomeLive.mount") 90 | 91 | assert :otel_attributes.map(attributes) == %{ 92 | duration_ms: 42, 93 | "liveview.callback": "mount", 94 | "liveview.module": "SomeWeb.SomeLive" 95 | } 96 | 97 | assert_instrumentation_scope(span) 98 | end 99 | 100 | test "records spans for the handle_params callback" do 101 | meta = Map.put(@meta, :uri, "https://foobar.com") 102 | 103 | :telemetry.execute( 104 | [:phoenix, :live_view, :handle_params, :start], 105 | %{system_time: System.system_time()}, 106 | meta 107 | ) 108 | 109 | :telemetry.execute( 110 | [:phoenix, :live_view, :handle_params, :stop], 111 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 112 | meta 113 | ) 114 | 115 | assert_receive {:span, 116 | span( 117 | name: "SomeWeb.SomeLive.handle_params", 118 | kind: :internal, 119 | attributes: attributes 120 | ) = span} 121 | 122 | assert :otel_attributes.map(attributes) == %{ 123 | duration_ms: 42, 124 | "liveview.callback": "handle_params", 125 | "liveview.module": "SomeWeb.SomeLive", 126 | "liveview.uri": "https://foobar.com" 127 | } 128 | 129 | assert_instrumentation_scope(span) 130 | end 131 | 132 | test "records exceptions for the handle_params callback" do 133 | meta = Map.put(@meta, :uri, "https://foobar.com") 134 | 135 | :telemetry.execute( 136 | [:phoenix, :live_view, :handle_params, :start], 137 | %{system_time: System.system_time()}, 138 | meta 139 | ) 140 | 141 | :telemetry.execute( 142 | [:phoenix, :live_view, :handle_params, :exception], 143 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 144 | Map.merge(meta, @bad_key_error) 145 | ) 146 | 147 | {span, attributes} = assert_receive_bad_key_error_span("SomeWeb.SomeLive.handle_params") 148 | 149 | assert :otel_attributes.map(attributes) == %{ 150 | duration_ms: 42, 151 | "liveview.callback": "handle_params", 152 | "liveview.module": "SomeWeb.SomeLive", 153 | "liveview.uri": "https://foobar.com" 154 | } 155 | 156 | assert_instrumentation_scope(span) 157 | end 158 | 159 | test "records spans for the handle_event callback" do 160 | meta = Map.put(@meta, :event, "some_event") 161 | 162 | :telemetry.execute( 163 | [:phoenix, :live_view, :handle_event, :start], 164 | %{system_time: System.system_time()}, 165 | meta 166 | ) 167 | 168 | :telemetry.execute( 169 | [:phoenix, :live_view, :handle_event, :stop], 170 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 171 | meta 172 | ) 173 | 174 | assert_receive {:span, 175 | span( 176 | name: "SomeWeb.SomeLive.some_event", 177 | kind: :internal, 178 | attributes: attributes 179 | ) = span} 180 | 181 | assert :otel_attributes.map(attributes) == %{ 182 | duration_ms: 42, 183 | "liveview.callback": "handle_event", 184 | "liveview.event": "some_event", 185 | "liveview.module": "SomeWeb.SomeLive" 186 | } 187 | 188 | assert_instrumentation_scope(span) 189 | 190 | # for live_component 191 | meta = %{socket: %{}, event: "some_event", component: SomeWeb.SomeComponent} 192 | 193 | :telemetry.execute( 194 | [:phoenix, :live_component, :handle_event, :start], 195 | %{system_time: System.system_time()}, 196 | meta 197 | ) 198 | 199 | :telemetry.execute( 200 | [:phoenix, :live_component, :handle_event, :stop], 201 | %{duration: System.convert_time_unit(42, :millisecond, :native)}, 202 | meta 203 | ) 204 | 205 | assert_receive {:span, 206 | span( 207 | name: "SomeWeb.SomeComponent.some_event", 208 | kind: :internal, 209 | attributes: attributes 210 | ) = span} 211 | 212 | assert :otel_attributes.map(attributes) == %{ 213 | duration_ms: 42, 214 | "liveview.callback": "handle_event", 215 | "liveview.event": "some_event", 216 | "liveview.module": "SomeWeb.SomeComponent" 217 | } 218 | 219 | assert_instrumentation_scope(span) 220 | end 221 | 222 | defp assert_receive_bad_key_error_span(name) do 223 | expected_status = OpenTelemetry.status(:error, "Erlang error: :badkey") 224 | 225 | assert_receive {:span, 226 | span( 227 | name: ^name, 228 | attributes: attributes, 229 | kind: :internal, 230 | events: events, 231 | status: ^expected_status 232 | ) = span} 233 | 234 | assert [event(name: "exception", attributes: exception_attributes)] = :otel_events.list(events) 235 | 236 | # The :map field is filtered because attribute values can only contain 237 | # primitives or lists of primitives (not maps). 238 | # 239 | # See https://opentelemetry.io/docs/reference/specification/common/common/#attributes 240 | assert %{ 241 | "exception.type" => "Elixir.ErlangError", 242 | "exception.message" => "Erlang error: :badkey", 243 | "exception.stacktrace" => _stacktrace, 244 | key: :name 245 | } = :otel_attributes.map(exception_attributes) 246 | 247 | {span, attributes} 248 | end 249 | 250 | defp assert_instrumentation_scope(span) do 251 | lib_from_otel = 252 | span 253 | |> span(:instrumentation_scope) 254 | |> instrumentation_scope() 255 | |> Map.new() 256 | 257 | opentelemetry_liveview_version = 258 | Application.loaded_applications() 259 | |> List.keyfind(:opentelemetry_liveview, 0) 260 | |> elem(2) 261 | |> to_string() 262 | 263 | assert %{name: "opentelemetry_liveview", version: ^opentelemetry_liveview_version} = 264 | lib_from_otel 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------