Skip to content

Commit 7b1c7b1

Browse files
authored
access_log: add a new COALESCE substitution formatter (#42711)
## Description This PR adds a new `COALESCE` substitution formatter that evaluates multiple formatter operators in sequence and returns the first non-null result. This could be used to have a fallback behavior such as using SNI when available but falling back to the `:authority` header when SNI is not available. **Example:** ``` %COALESCE( { "operators": [ "REQUESTED_SERVER_NAME", {"command": "REQ", "param": ":authority"}, {"command": "REQ", "param": "x-envoy-original-host"} ] } )% ``` --- **Commit Message:** access_log: add a new **COALESCE** substitution formatter **Additional Description:** Adds a new `COALESCE` substitution formatter that evaluates multiple formatter operators in sequence and returns the first non-null result. **Risk Level:** Low **Testing:** Added Unit + Integration Tests **Docs Changes:** Added **Release Notes:** Added Signed-off-by: Rohit Agrawal <[email protected]>
1 parent f814677 commit 7b1c7b1

File tree

8 files changed

+679
-0
lines changed

8 files changed

+679
-0
lines changed

changelogs/current.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ new_features:
277277
change: |
278278
Added process-level rate limiting on access log emission via
279279
:ref:`ProcessRateLimitFilter <envoy_v3_api_msg_extensions.access_loggers.filters.process_ratelimit.v3.ProcessRateLimitFilter>`.
280+
- area: access_log
281+
change: |
282+
Added :ref:`COALESCE <config_access_log_format_coalesce>` substitution formatter operator that evaluates multiple
283+
formatter operators in sequence and returns the first non-null result. This enables fallback behavior such as
284+
using SNI when available but falling back to the ``:authority`` header when SNI is not set.
280285
- area: listener_filters
281286
change: |
282287
Added :ref:`Postgres Inspector <config_listener_filters_postgres_inspector>` listener filter for detecting

docs/root/configuration/observability/access_log/usage.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,3 +1487,61 @@ UDP
14871487
%CUSTOM_FLAGS%
14881488
Custom flags set into the stream info. This could be used to log any custom event from the filters.
14891489
Multiple flags are separated by comma.
1490+
1491+
.. _config_access_log_format_coalesce:
1492+
1493+
%COALESCE(JSON_CONFIG):Z%
1494+
HTTP
1495+
A higher-order formatter operator that evaluates multiple formatter operators in sequence and
1496+
returns the first non-null, non-empty result. This is useful for implementing fallback behavior,
1497+
such as using SNI when available but falling back to the ``:authority`` header when SNI is not set.
1498+
1499+
The ``JSON_CONFIG`` parameter is a JSON object with an ``operators`` array. Each operator can be
1500+
specified as either:
1501+
1502+
* A string representing a simple command name that does not require a parameter.
1503+
* An object with the following fields:
1504+
1505+
* ``command`` (required): The command name (e.g., ``REQ``, ``REQUESTED_SERVER_NAME``).
1506+
* ``param`` (optional): The command parameter (e.g., ``:authority`` for the ``REQ`` command).
1507+
* ``max_length`` (optional): Maximum length for this operator's output.
1508+
1509+
``Z`` is an optional parameter denoting string truncation up to ``Z`` characters for the final output.
1510+
1511+
.. note::
1512+
1513+
The JSON parameter cannot contain literal ``)`` characters as they would interfere with the
1514+
command parser. If you need a ``)`` character in a string value, use the Unicode escape
1515+
sequence ``\u0029``.
1516+
1517+
**Example: SNI with fallback to authority header**
1518+
1519+
.. code-block:: none
1520+
1521+
%COALESCE({"operators": ["REQUESTED_SERVER_NAME", {"command": "REQ", "param": ":authority"}]})%
1522+
1523+
This returns the Server Name Indication (SNI) if available, otherwise falls back to the
1524+
``:authority`` header.
1525+
1526+
**Example: Cascade fallback with multiple headers**
1527+
1528+
.. code-block:: none
1529+
1530+
%COALESCE({"operators": ["REQUESTED_SERVER_NAME", {"command": "REQ", "param": ":authority"}, {"command": "REQ", "param": "x-envoy-original-host"}]})%
1531+
1532+
This tries SNI first, then ``:authority``, then ``x-envoy-original-host``.
1533+
1534+
**Example: With length truncation**
1535+
1536+
.. code-block:: none
1537+
1538+
%COALESCE({"operators": [{"command": "REQ", "param": ":authority"}]}):50%
1539+
1540+
This returns the ``:authority`` header value truncated to 50 characters.
1541+
1542+
**Supported Commands**
1543+
1544+
The ``COALESCE`` operator supports any built-in formatter command.
1545+
1546+
TCP/UDP
1547+
Not implemented ("-").

source/common/formatter/BUILD

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,27 @@ envoy_cc_library(
6363
],
6464
)
6565

66+
envoy_cc_library(
67+
name = "coalesce_formatter_lib",
68+
srcs = ["coalesce_formatter.cc"],
69+
hdrs = ["coalesce_formatter.h"],
70+
deps = [
71+
":substitution_format_utility_lib",
72+
"//envoy/formatter:substitution_formatter_interface",
73+
"//envoy/json:json_object_interface",
74+
"//envoy/stream_info:stream_info_interface",
75+
"//source/common/common:fmt_lib",
76+
"//source/common/common:statusor_lib",
77+
"//source/common/json:json_loader_lib",
78+
],
79+
)
80+
6681
envoy_cc_library(
6782
name = "http_speicific_formatter_extension_lib",
6883
srcs = ["http_specific_formatter.cc"],
6984
hdrs = ["http_specific_formatter.h"],
7085
deps = [
86+
":coalesce_formatter_lib",
7187
"//envoy/api:api_interface",
7288
"//envoy/formatter:substitution_formatter_interface",
7389
"//envoy/runtime:runtime_interface",
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#include "source/common/formatter/coalesce_formatter.h"
2+
3+
#include "source/common/common/fmt.h"
4+
#include "source/common/json/json_loader.h"
5+
6+
namespace Envoy {
7+
namespace Formatter {
8+
9+
absl::StatusOr<FormatterProviderPtr> CoalesceFormatter::create(absl::string_view json_config,
10+
absl::optional<size_t> max_length) {
11+
if (json_config.empty()) {
12+
return absl::InvalidArgumentError("COALESCE requires a JSON configuration parameter");
13+
}
14+
15+
auto json_or_error = Json::Factory::loadFromString(std::string(json_config));
16+
if (!json_or_error.ok()) {
17+
return absl::InvalidArgumentError(fmt::format(
18+
"COALESCE: failed to parse JSON configuration: {}", json_or_error.status().message()));
19+
}
20+
21+
const auto& json = *json_or_error.value();
22+
23+
if (!json.hasObject("operators")) {
24+
return absl::InvalidArgumentError(
25+
"COALESCE: JSON configuration must contain 'operators' array");
26+
}
27+
28+
auto operators_or_error = json.getObjectArray("operators");
29+
if (!operators_or_error.ok()) {
30+
return absl::InvalidArgumentError(fmt::format("COALESCE: 'operators' must be an array: {}",
31+
operators_or_error.status().message()));
32+
}
33+
34+
const auto& operators = operators_or_error.value();
35+
if (operators.empty()) {
36+
return absl::InvalidArgumentError("COALESCE: 'operators' array must not be empty");
37+
}
38+
39+
std::vector<FormatterProviderPtr> formatters;
40+
formatters.reserve(operators.size());
41+
42+
for (size_t i = 0; i < operators.size(); ++i) {
43+
const auto& entry = operators[i];
44+
auto formatter_or_error = parseOperatorEntry(*entry);
45+
if (!formatter_or_error.ok()) {
46+
return absl::InvalidArgumentError(
47+
fmt::format("COALESCE: failed to parse operator at index {}: {}", i,
48+
formatter_or_error.status().message()));
49+
}
50+
formatters.push_back(std::move(formatter_or_error.value()));
51+
}
52+
53+
return std::make_unique<CoalesceFormatter>(std::move(formatters), max_length);
54+
}
55+
56+
absl::StatusOr<FormatterProviderPtr>
57+
CoalesceFormatter::parseOperatorEntry(const Json::Object& entry) {
58+
// Check if this is a simple string command with command-only and no parameters.
59+
auto string_value = entry.asString();
60+
if (string_value.ok()) {
61+
return createFormatterForCommand(string_value.value(), "", absl::nullopt);
62+
}
63+
64+
// Otherwise, it should be an object with "command" field.
65+
if (!entry.isObject()) {
66+
return absl::InvalidArgumentError(
67+
"operator entry must be either a string (command name) or an object with 'command' field");
68+
}
69+
70+
auto command_or_error = entry.getString("command");
71+
if (!command_or_error.ok()) {
72+
return absl::InvalidArgumentError(fmt::format("operator object must have 'command' field: {}",
73+
command_or_error.status().message()));
74+
}
75+
76+
std::string param;
77+
if (entry.hasObject("param")) {
78+
auto param_or_error = entry.getString("param");
79+
if (!param_or_error.ok()) {
80+
return absl::InvalidArgumentError(
81+
fmt::format("'param' field must be a string: {}", param_or_error.status().message()));
82+
}
83+
param = param_or_error.value();
84+
}
85+
86+
absl::optional<size_t> entry_max_length;
87+
if (entry.hasObject("max_length")) {
88+
auto max_length_or_error = entry.getInteger("max_length");
89+
if (!max_length_or_error.ok()) {
90+
return absl::InvalidArgumentError(fmt::format("'max_length' field must be an integer: {}",
91+
max_length_or_error.status().message()));
92+
}
93+
if (max_length_or_error.value() <= 0) {
94+
return absl::InvalidArgumentError("'max_length' must be a positive integer");
95+
}
96+
entry_max_length = static_cast<size_t>(max_length_or_error.value());
97+
}
98+
99+
return createFormatterForCommand(command_or_error.value(), param, entry_max_length);
100+
}
101+
102+
absl::StatusOr<FormatterProviderPtr>
103+
CoalesceFormatter::createFormatterForCommand(absl::string_view command, absl::string_view param,
104+
absl::optional<size_t> max_length) {
105+
// Try built-in command parsers to create the formatter.
106+
for (const auto& parser : BuiltInCommandParserFactoryHelper::commandParsers()) {
107+
auto formatter = parser->parse(command, param, max_length);
108+
if (formatter != nullptr) {
109+
return formatter;
110+
}
111+
}
112+
113+
return absl::InvalidArgumentError(fmt::format("unknown command: '{}'", command));
114+
}
115+
116+
absl::optional<std::string>
117+
CoalesceFormatter::format(const Context& context, const StreamInfo::StreamInfo& stream_info) const {
118+
for (const auto& formatter : formatters_) {
119+
auto result = formatter->format(context, stream_info);
120+
if (result.has_value() && !result.value().empty()) {
121+
if (max_length_.has_value()) {
122+
SubstitutionFormatUtils::truncate(result.value(), max_length_.value());
123+
}
124+
return result;
125+
}
126+
}
127+
return absl::nullopt;
128+
}
129+
130+
Protobuf::Value CoalesceFormatter::formatValue(const Context& context,
131+
const StreamInfo::StreamInfo& stream_info) const {
132+
for (const auto& formatter : formatters_) {
133+
auto result = formatter->formatValue(context, stream_info);
134+
// Check if this is a valid non-null value.
135+
if (result.kind_case() != Protobuf::Value::KIND_NOT_SET &&
136+
result.kind_case() != Protobuf::Value::kNullValue) {
137+
// For string values, also check if empty.
138+
if (result.kind_case() == Protobuf::Value::kStringValue) {
139+
if (!result.string_value().empty()) {
140+
if (max_length_.has_value() && result.string_value().size() > max_length_.value()) {
141+
result.set_string_value(result.string_value().substr(0, max_length_.value()));
142+
}
143+
return result;
144+
}
145+
} else {
146+
return result;
147+
}
148+
}
149+
}
150+
return SubstitutionFormatUtils::unspecifiedValue();
151+
}
152+
153+
} // namespace Formatter
154+
} // namespace Envoy
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#pragma once
2+
3+
#include <string>
4+
#include <vector>
5+
6+
#include "envoy/formatter/substitution_formatter.h"
7+
#include "envoy/json/json_object.h"
8+
#include "envoy/stream_info/stream_info.h"
9+
10+
#include "source/common/common/statusor.h"
11+
#include "source/common/formatter/substitution_format_utility.h"
12+
13+
#include "absl/types/optional.h"
14+
15+
namespace Envoy {
16+
namespace Formatter {
17+
18+
/**
19+
* CoalesceFormatter provides a higher-order formatter that evaluates multiple
20+
* formatter operators in sequence and returns the first non-null result.
21+
*
22+
* This formatter accepts a JSON configuration specifying an array of operators
23+
* to evaluate. Each operator can be either:
24+
* - A string representing a simple command (e.g., "REQUESTED_SERVER_NAME")
25+
* - An object with "command" and optional "param" and "max_length" fields
26+
*
27+
* Example JSON configuration:
28+
* {
29+
* "operators": [
30+
* "REQUESTED_SERVER_NAME",
31+
* {"command": "REQ", "param": ":authority"},
32+
* {"command": "REQ", "param": "host"}
33+
* ]
34+
* }
35+
*
36+
* Note that the JSON parameter cannot contain literal ')' characters as they would
37+
* interfere with the command parser regex.
38+
*/
39+
class CoalesceFormatter : public FormatterProvider {
40+
public:
41+
/**
42+
* Creates a CoalesceFormatter from a JSON configuration string.
43+
* @param json_config the JSON configuration string.
44+
* @param max_length optional maximum length for the output.
45+
* @return StatusOr containing the formatter or an error.
46+
*/
47+
static absl::StatusOr<FormatterProviderPtr> create(absl::string_view json_config,
48+
absl::optional<size_t> max_length);
49+
50+
CoalesceFormatter(std::vector<FormatterProviderPtr>&& formatters,
51+
absl::optional<size_t> max_length)
52+
: formatters_(std::move(formatters)), max_length_(max_length) {}
53+
54+
// FormatterProvider interface.
55+
absl::optional<std::string> format(const Context& context,
56+
const StreamInfo::StreamInfo& stream_info) const override;
57+
Protobuf::Value formatValue(const Context& context,
58+
const StreamInfo::StreamInfo& stream_info) const override;
59+
60+
private:
61+
/**
62+
* Parses a single operator entry from the JSON configuration.
63+
* @param entry the JSON object representing an operator entry.
64+
* @return StatusOr containing the formatter or an error.
65+
*/
66+
static absl::StatusOr<FormatterProviderPtr> parseOperatorEntry(const Json::Object& entry);
67+
68+
/**
69+
* Creates a formatter for the given command using built-in command parsers.
70+
* @param command the command name.
71+
* @param param the command parameter (may be empty).
72+
* @param max_length optional maximum length.
73+
* @return StatusOr containing the formatter or an error.
74+
*/
75+
static absl::StatusOr<FormatterProviderPtr>
76+
createFormatterForCommand(absl::string_view command, absl::string_view param,
77+
absl::optional<size_t> max_length);
78+
79+
std::vector<FormatterProviderPtr> formatters_;
80+
absl::optional<size_t> max_length_;
81+
};
82+
83+
} // namespace Formatter
84+
} // namespace Envoy

source/common/formatter/http_specific_formatter.cc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "source/common/common/thread.h"
77
#include "source/common/common/utility.h"
88
#include "source/common/config/metadata.h"
9+
#include "source/common/formatter/coalesce_formatter.h"
910
#include "source/common/grpc/common.h"
1011
#include "source/common/grpc/status.h"
1112
#include "source/common/http/header_map_impl.h"
@@ -502,6 +503,12 @@ BuiltInHttpCommandParser::getKnownFormatters() {
502503
SubstitutionFormatUtils::parseSubcommand(format, ':', query, option);
503504
return THROW_OR_RETURN_VALUE(PathFormatter::create(query, option, max_length),
504505
FormatterProviderPtr);
506+
}}},
507+
{"COALESCE",
508+
{CommandSyntaxChecker::PARAMS_REQUIRED | CommandSyntaxChecker::LENGTH_ALLOWED,
509+
[](absl::string_view format, absl::optional<size_t> max_length) {
510+
return THROW_OR_RETURN_VALUE(CoalesceFormatter::create(format, max_length),
511+
FormatterProviderPtr);
505512
}}}});
506513
}
507514

0 commit comments

Comments
 (0)