From 029ec5351925bc4df9b0d74c8049dd930102e5ca Mon Sep 17 00:00:00 2001 From: Gustavo Aguiar Date: Sat, 2 Aug 2025 12:03:32 -0300 Subject: [PATCH] Add baggage span processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a new span processor that takes attributes from OpenTelemetry Baggage and inserts them into spans when they start. Features: - Automatically adds all baggage entries as span attributes - Optional prefix configuration for baggage attributes - Optional filter to only include baggage with specific metadata - Comprehensive test suite The processor is configured in the OpenTelemetry processors list and must be placed before any exporting processor (e.g., batch processor). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../opentelemetry_baggage_processor/LICENSE | 201 ++++++++++++++++++ .../opentelemetry_baggage_processor/README.md | 69 ++++++ .../docs.config | 4 + .../opentelemetry_baggage_processor/docs.sh | 16 ++ .../rebar.config | 21 ++ .../rebar.lock | 11 + .../opentelemetry_baggage_processor.app.src | 15 ++ .../src/otel_baggage_processor.erl | 68 ++++++ .../test/otel_baggage_processor_SUITE.erl | 95 +++++++++ 9 files changed, 500 insertions(+) create mode 100644 processors/opentelemetry_baggage_processor/LICENSE create mode 100644 processors/opentelemetry_baggage_processor/README.md create mode 100644 processors/opentelemetry_baggage_processor/docs.config create mode 100755 processors/opentelemetry_baggage_processor/docs.sh create mode 100644 processors/opentelemetry_baggage_processor/rebar.config create mode 100644 processors/opentelemetry_baggage_processor/rebar.lock create mode 100644 processors/opentelemetry_baggage_processor/src/opentelemetry_baggage_processor.app.src create mode 100644 processors/opentelemetry_baggage_processor/src/otel_baggage_processor.erl create mode 100644 processors/opentelemetry_baggage_processor/test/otel_baggage_processor_SUITE.erl diff --git a/processors/opentelemetry_baggage_processor/LICENSE b/processors/opentelemetry_baggage_processor/LICENSE new file mode 100644 index 00000000..f49a4e16 --- /dev/null +++ b/processors/opentelemetry_baggage_processor/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/README.md b/processors/opentelemetry_baggage_processor/README.md new file mode 100644 index 00000000..3cbc2b09 --- /dev/null +++ b/processors/opentelemetry_baggage_processor/README.md @@ -0,0 +1,69 @@ +# opentelemetry_baggage_processor + +A Span Processor that takes attributes from the Baggage and insert into the Span. + +[![EEF Observability WG project](https://img.shields.io/badge/EEF-Observability-black)](https://github.com/erlef/eef-observability-wg) +[![Hex.pm](https://img.shields.io/hexpm/v/opentelemetry_baggage_processor)](https://hex.pm/packages/opentelemetry_baggage_processor) + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `opentelemetry_baggage_processor` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:opentelemetry_baggage_processor, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/opentelemetry_baggage_processor](https://hexdocs.pm/opentelemetry_baggage_processor). + +## Usage + + + +`opentelemetry_baggage_processor` provides a [Span Processor](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#span-processor) +that takes attributes from the [Baggage](https://hexdocs.pm/opentelemetry_api/otel_baggage.html) +and insert into the Span, once it starts. + +A Span Processor is not an application, to use it you must update your +configuration: + +```elixir +# config/config.exs + +config :opentelemetry, processors, + otel_baggage_processor: %{}}, + otel_batch_processor: %{ + exporter: {:opentelemetry_exporter, %{}} + } +``` + +The processor configuration is order-dependent, so `otel_baggage_processor` +configuration must come before the processor used for exporting – in this case, +`otel_batch_processor`. + +Now every new span should have what's inside your baggage as attribute. + +### Options + +* `:prefix` - adds a prefix for all baggage attributes. +* `:filter` - only add attributes if the baggage metadata has the configured key. +The key must be a binary. + +### Limitations + +Baggage will follow the Context. So any limitation to Context Propagation applies +to Baggage Propagation, and thus to what attributes are going to be added to your +Span. + +We can only apply attributes on Span's start, since that's when we can modify +them. There's a [BeforeEnd callback proposal](https://github.com/open-telemetry/opentelemetry-specification/issues/1089) +which would allow us to add the Baggage's attribute on Span's end too, but that +remains as something to be revisited in the future. + + \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/docs.config b/processors/opentelemetry_baggage_processor/docs.config new file mode 100644 index 00000000..e3716526 --- /dev/null +++ b/processors/opentelemetry_baggage_processor/docs.config @@ -0,0 +1,4 @@ +{source_url, <<"https://github.com/open-telemetry/opentelemetry-erlang-contrib">>}. +{extras, [<<"README.md">>]}. +{main, <<"readme">>}. +{proglang, erlang}. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/docs.sh b/processors/opentelemetry_baggage_processor/docs.sh new file mode 100755 index 00000000..4471e283 --- /dev/null +++ b/processors/opentelemetry_baggage_processor/docs.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Setup: +# +# # 1. install OTP 24+ +# # 2. install ExDoc: +# $ mix escript.install github elixir-lang/ex_doc + +rebar3 compile +rebar3 edoc + +ex_doc "opentelemetry_baggage_processor" 0.0.1 "_build/default/lib/opentelemetry_baggage_processor/ebin" \ + --source-ref v0.0.1 \ + --config docs.config $@ \ + --output "doc" \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/rebar.config b/processors/opentelemetry_baggage_processor/rebar.config new file mode 100644 index 00000000..44b74cbc --- /dev/null +++ b/processors/opentelemetry_baggage_processor/rebar.config @@ -0,0 +1,21 @@ +{erl_opts, [debug_info]}. +{deps, [ + {opentelemetry_api, "~> 1.2"} +]}. + +{project_plugins, [covertool, + rebar3_fmt, + rebar3_hex, + rebar3_hank]}. + +{profiles, + [{docs, [{deps, [edown]}]}, + {github, [{plugins, [{rebar3_ex_doc, "0.2.9"}]}, + {ex_doc, [{source_url, <<"https://github.com/open-telemetry/opentelemetry-erlang-contrib">>}, + {subpackages, true}]}]}, + {test, [{erl_opts, [nowarn_export_all]}, + {deps, [ + {opentelemetry, "~> 1.3"} + ]}, + {paths, ["src", "test/support"]}, + {ct_opts, [{ct_hooks, [cth_surefire]}]}]}]}. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/rebar.lock b/processors/opentelemetry_baggage_processor/rebar.lock new file mode 100644 index 00000000..5c4b4c0b --- /dev/null +++ b/processors/opentelemetry_baggage_processor/rebar.lock @@ -0,0 +1,11 @@ +{"1.2.0", +[{<<"opentelemetry">>,{pkg,<<"opentelemetry">>,<<"1.0.4">>},0}, + {<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.0.3">>},0}]}. +[ +{pkg_hash,[ + {<<"opentelemetry">>, <<"A7DAF00A248715EE72C12BA1CBAD878837E8DCA6E9BEA12A2381A1F27DF9EBB2">>}, + {<<"opentelemetry_api">>, <<"77F9644C42340CD8B18C728CDE4822ED55AE136F0D07761B78E8C54DA46AF93A">>}]}, +{pkg_hash_ext,[ + {<<"opentelemetry">>, <<"D75C3931884817679CA63C21395715427457691F5C9BE06D75B71D74ADC9D0B4">>}, + {<<"opentelemetry_api">>, <<"4293E06BD369BC004E6FAD5EDBB56456D891F14BD3F9F1772B18F1923E0678EA">>}]} +]. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/src/opentelemetry_baggage_processor.app.src b/processors/opentelemetry_baggage_processor/src/opentelemetry_baggage_processor.app.src new file mode 100644 index 00000000..439e02cc --- /dev/null +++ b/processors/opentelemetry_baggage_processor/src/opentelemetry_baggage_processor.app.src @@ -0,0 +1,15 @@ +{application, opentelemetry_baggage_processor, + [{description, "A Span Processor that takes attributes from the Baggage and insert into the Span"}, + {vsn, git}, + {registered, []}, + {applications, + [kernel, + stdlib, + opentelemetry + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["Apache 2.0"]}, + {links, [{"GitHub", "https://github.com/open-telemetry/opentelemetry-erlang-contrib/tree/main/processors/opentelemetry_baggage_processor"}]} + ]}. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/src/otel_baggage_processor.erl b/processors/opentelemetry_baggage_processor/src/otel_baggage_processor.erl new file mode 100644 index 00000000..ebcf9b4c --- /dev/null +++ b/processors/opentelemetry_baggage_processor/src/otel_baggage_processor.erl @@ -0,0 +1,68 @@ +-module(otel_baggage_processor). + +-behaviour(otel_span_processor). + +-include_lib("opentelemetry/include/otel_span.hrl"). +-include_lib("opentelemetry_api/include/opentelemetry.hrl"). + +-export([on_start/3, on_end/2, force_flush/1]). + +-type processor_config() :: term(). + +-spec on_start(otel_ctx:t(), opentelemetry:span(), processor_config()) -> + opentelemetry:span(). +on_start(Ctx, Span, Config) -> + Baggage = otel_baggage:get_all(Ctx), + Prefix = maps:get(prefix, Config, undefined), + FilterKey = maps:get(filter, Config, undefined), + Attributes = + maps:fold(fun(Key, {Value, Metadata}, Attributes) -> + NewKey = add_prefix(Key, Prefix), + case filter(Metadata, FilterKey) of + false -> Attributes; + true -> [{NewKey, Value}] ++ Attributes + end + end, + [], + Baggage), + add_attributes(Span, Attributes). + +-spec on_end(opentelemetry:span(), processor_config()) -> + true | dropped | {error, invalid_span} | {error, no_export_buffer}. +on_end(_Span, _Config) -> + true. + +-spec force_flush(processor_config()) -> ok | {error, term()}. +force_flush(_Config) -> + ok. + +-spec add_attributes(opentelemetry:span(), opentelemetry:attributes_map()) -> + opentelemetry:span(). +add_attributes(Span = #span{attributes = SpanAttributes}, AttributesMap) -> + Span#span{attributes = otel_attributes:set(AttributesMap, SpanAttributes)}. + +-spec filter(otel_baggage:metadata(), map()) -> boolean(). +filter(_Metadata, undefined) -> + true; +filter(Metadata, FilterKey) -> + case lists:search(fun (Key) when Key == FilterKey -> + true; + (_) -> + false + end, + Metadata) + of + false -> + false; + {value, _} -> + true + end. + +-spec add_prefix(opentelemetry:attribute_key(), map()) -> opentelemetry:attribute_key(). +add_prefix(Key, Prefix) when is_binary(Key), is_binary(Prefix) -> + <>; +add_prefix(Key, Prefix) when is_atom(Key), is_binary(Prefix) -> + Key2 = atom_to_binary(Key), + <>; +add_prefix(Key, _Prefix) -> + Key. \ No newline at end of file diff --git a/processors/opentelemetry_baggage_processor/test/otel_baggage_processor_SUITE.erl b/processors/opentelemetry_baggage_processor/test/otel_baggage_processor_SUITE.erl new file mode 100644 index 00000000..adefd3a9 --- /dev/null +++ b/processors/opentelemetry_baggage_processor/test/otel_baggage_processor_SUITE.erl @@ -0,0 +1,95 @@ +-module(otel_baggage_processor_SUITE). + +-compile(export_all). + +-include_lib("stdlib/include/assert.hrl"). +-include_lib("opentelemetry/include/otel_span.hrl"). +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). + +all() -> + [baggage_handling, add_prefix_to_attributes, filter_baggage_attributes]. + +init_per_suite(Config) -> + ok = application:load(opentelemetry_baggage_processor), + ok = application:load(opentelemetry), + application:set_env(opentelemetry, + processors, + [{otel_baggage_processor, #{}}, + {otel_batch_processor, #{scheduled_delay_ms => 1}}]), + Config. + +end_per_suite(_Config) -> + ok = application:unload(opentelemetry), + ok. + +init_per_testcase(_, Config) -> + {ok, _} = application:ensure_all_started(opentelemetry_baggage_processor), + Config. + +end_per_testcase(_, Config) -> + application:stop(opentelemetry), + Config. + +baggage_handling(_Config) -> + {ok, _} = application:ensure_all_started(opentelemetry), + otel_batch_processor:set_exporter(otel_exporter_pid, self()), + SpanCtx1 = ?start_span(<<"span-1">>), + ?set_current_span(SpanCtx1), + Ctx = otel_ctx:get_current(), + Ctx2 = otel_baggage:set(Ctx, <<"key">>, <<"value">>), + _Token = otel_ctx:attach(Ctx2), + SpanCtx2 = + ?start_span(<<"span-2">>, #{attributes => #{<<"existing-attribute">> => true}}), + ?end_span(), + ?set_current_span(SpanCtx2), + ?end_span(), + Attributes = get_span_attributes(<<"span-1">>), + ?assertEqual(Attributes, #{}), + Attributes2 = get_span_attributes(<<"span-2">>), + ?assertEqual(Attributes2, #{<<"key">> => <<"value">>, <<"existing-attribute">> => true}), + ok. + +add_prefix_to_attributes(_Config) -> + application:set_env(opentelemetry, + processors, + [{otel_baggage_processor, #{prefix => <<"app.">>}}, + {otel_batch_processor, #{scheduled_delay_ms => 1}}]), + {ok, _} = application:ensure_all_started(opentelemetry), + otel_batch_processor:set_exporter(otel_exporter_pid, self()), + Ctx = otel_ctx:get_current(), + Ctx2 = otel_baggage:set(Ctx, <<"key">>, <<"value">>), + Ctx3 = otel_baggage:set(Ctx2, atom_key, <<"value">>), + _Token = otel_ctx:attach(Ctx3), + SpanCtx1 = ?start_span(<<"span-1">>), + ?set_current_span(SpanCtx1), + ?end_span(), + Attributes = get_span_attributes(<<"span-1">>), + ?assertEqual(#{<<"app.key">> => <<"value">>, <<"app.atom_key">> => <<"value">>}, + Attributes), + ok. + +filter_baggage_attributes(_Config) -> + application:set_env(opentelemetry, + processors, + [{otel_baggage_processor, #{filter => <<"trace_field">>}}, + {otel_batch_processor, #{scheduled_delay_ms => 1}}]), + {ok, _} = application:ensure_all_started(opentelemetry), + otel_batch_processor:set_exporter(otel_exporter_pid, self()), + Ctx = otel_ctx:get_current(), + Ctx2 = otel_baggage:set(Ctx, <<"key">>, <<"value">>), + Ctx3 = otel_baggage:set(Ctx2, atom_key, <<"value">>, [<<"trace_field">>]), + _Token = otel_ctx:attach(Ctx3), + SpanCtx1 = ?start_span(<<"span-1">>), + ?set_current_span(SpanCtx1), + ?end_span(), + Attributes = get_span_attributes(<<"span-1">>), + ?assertEqual(#{<<"atom_key">> => <<"value">>}, Attributes), + ok. + +get_span_attributes(Name) -> + receive + {span, #span{name = Name, attributes = Attributes}} -> + otel_attributes:map(Attributes) + after 100 -> + error(timeout) + end. \ No newline at end of file