You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by cm...@apache.org on 2026/04/21 15:46:07 UTC

(trafficserver) branch 10.2.x updated: Add log field fallbacks (#13018) (#13109)

This is an automated email from the ASF dual-hosted git repository.

cmcfarlen pushed a commit to branch 10.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/10.2.x by this push:
     new e2b283bb0b Add log field fallbacks (#13018) (#13109)
e2b283bb0b is described below

commit e2b283bb0b1821fa9d9f23767bc920a7adee7083
Author: Brian Neradt <br...@gmail.com>
AuthorDate: Tue Apr 21 10:46:01 2026 -0500

    Add log field fallbacks (#13018) (#13109)
    
    Allow custom log formats to fall back between header fields with
    `??` so configs can log a secondary header when the primary one is
    absent. This keeps the feature in the existing `%<...>` syntax and
    preserves the current distinction between missing and empty values.
    
    This also:
    
    - Allows overriding the default static fallback from `-` to
    some user-specified quoted string literal.
    - Adds support for a final non-header log field (e.g., chi, cqup) as
    the fallback term, letting users write expressions like
    %<{x-remote-ip}cqh??chi>.
    
    (cherry picked from commit c478245dfb6ead66186c2b413371f6ea4cda4d3d)
---
 doc/admin-guide/logging/formatting.en.rst          |  50 +++-
 include/proxy/logging/LogAccess.h                  |  23 +-
 include/proxy/logging/LogField.h                   |  23 ++
 src/proxy/logging/CMakeLists.txt                   |   6 +
 src/proxy/logging/LogAccess.cc                     | 104 ++++---
 src/proxy/logging/LogField.cc                      | 195 ++++++++++++-
 src/proxy/logging/LogFieldFallback.cc              | 315 +++++++++++++++++++++
 src/proxy/logging/LogFieldFallback.h               |  60 ++++
 src/proxy/logging/LogFormat.cc                     |  72 ++++-
 .../logging/unit-tests/test_LogFieldFallback.cc    | 120 ++++++++
 tests/gold_tests/logging/gold/field-test.gold      |   6 +-
 tests/gold_tests/logging/log-field.test.py         |  14 +-
 12 files changed, 905 insertions(+), 83 deletions(-)

diff --git a/doc/admin-guide/logging/formatting.en.rst b/doc/admin-guide/logging/formatting.en.rst
index b85e6407ad..f504cab2d6 100644
--- a/doc/admin-guide/logging/formatting.en.rst
+++ b/doc/admin-guide/logging/formatting.en.rst
@@ -314,8 +314,54 @@ prior to the log field's name, as so::
     Format = '%<{User-agent}cqh>'
 
 The above would insert the User Agent string from the client request headers
-into your log entry (or a blank string if no such header was present, or it did
-not contain a value).
+into your log entry (or ``-`` if no such header was present).
+
+Header fields can also be chained with a fallback operator, ``??``, when you want
+the log to use the first header that exists among n headers. For example::
+
+    Format = '%<{x-primary-id}cqh??{x-secondary-id}cqh??{x-tertiary-id}cqh>'
+
+|TS| evaluates the candidates from left to right and logs the first header that
+exists. If none of the headers exist, |TS| logs ``-`` by default. A header that
+exists but has an empty value is considered present, so |TS| logs the empty
+value instead of falling back. So in the example above, the value of
+x-primary-id of the client request is logged if it exists, otherwise the value
+of x-secondary-id is logged if it exists, otherwise ``-`` is logged if neither
+of the headers is present.
+
+The final log field in the chain can be a non-header log field whose value
+specifies the final fallback in the chain. This symbol is used only after all
+the previous header candidates in the chain are missing. For example::
+
+    Format = '%<{x-remote-ip}cqh??chi>'
+
+In this case, the value of the x-remote-ip HTTP header field is logged if that
+client request header exists. Otherwise, |TS| logs the value of ``chi``, the IP
+address of the client host.
+
+The final non-HTTP header log field must be the last term in the chain, so
+forms like ``%<chi??{x-id}cqh>`` are invalid.
+
+Alternatively, you can provide an explicit quoted default literal as the final
+term in the chain to use instead of the default ``-`` literal::
+
+    Format = '%<{x-primary-id}cqh??{x-secondary-id}cqh??"missing-id">'
+
+If none of the headers exist, |TS| logs the default literal instead,
+``missing-id`` in this case. The default literal must be quoted and must be the
+last term in the chain. Also, final non-header log fields and final default
+string literals cannot be used together. Thus forms like
+'%<{x-remote-ip}cqh??chi??"missing-id">' are invalid.
+
+
+Slices apply to each candidate in the fallback chain individually::
+
+    Format = '%<{x-primary-id}cqh[0:8]??{x-secondary-id}cqh[0:16]>'
+
+This is also true of non-header log fields. That is, if the final log field
+supports slicing, its own slice is preserved as usual::
+
+    Format = '%<{x-remote-ip}cqh??pqup[0:8]>'
 
 ===== ====================== ==================================================
 Field Source                 Description
diff --git a/include/proxy/logging/LogAccess.h b/include/proxy/logging/LogAccess.h
index 7cdf8ed48b..c062e5af0d 100644
--- a/include/proxy/logging/LogAccess.h
+++ b/include/proxy/logging/LogAccess.h
@@ -296,14 +296,16 @@ public:
   //
   // milestones access
   //
-  int  marshal_milestone(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_sec(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_squid(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_netscape(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_date(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_time(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_fmt_ms(TSMilestonesType ms, char *buf);
-  int  marshal_milestone_diff(TSMilestonesType ms1, TSMilestonesType ms2, char *buf);
+  int marshal_milestone(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_sec(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_squid(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_netscape(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_date(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_time(TSMilestonesType ms, char *buf);
+  int marshal_milestone_fmt_ms(TSMilestonesType ms, char *buf);
+  int marshal_milestone_diff(TSMilestonesType ms1, TSMilestonesType ms2, char *buf);
+
+  bool has_http_header_field(LogField::Container container, const char *field) const;
   void set_http_header_field(LogField::Container container, char *field, char *buf, int len);
   //
   // unmarshalling routines
@@ -398,8 +400,9 @@ private:
   char       *m_cache_lookup_url_canon_str        = nullptr;
   int         m_cache_lookup_url_canon_len        = 0;
 
-  void validate_unmapped_url();
-  void validate_unmapped_url_path();
+  HTTPHdr *header_for_container(LogField::Container container) const;
+  void     validate_unmapped_url();
+  void     validate_unmapped_url_path();
 
   void validate_lookup_url();
 };
diff --git a/include/proxy/logging/LogField.h b/include/proxy/logging/LogField.h
index dcc5b23bb0..b2da447b61 100644
--- a/include/proxy/logging/LogField.h
+++ b/include/proxy/logging/LogField.h
@@ -23,9 +23,12 @@
 
 #pragma once
 
+#include <memory>
+#include <optional>
 #include <string_view>
 #include <string>
 #include <variant>
+#include <vector>
 
 #include "tscore/ink_inet.h"
 #include "tscore/ink_platform.h"
@@ -126,6 +129,12 @@ public:
     N_AGGREGATES,
   };
 
+  struct HeaderField {
+    std::string name;
+    Container   container = NO_CONTAINER;
+    LogSlice    slice;
+  };
+
   LogField(const char *name, const char *symbol, Type type, MarshalFunc marshal, VarUnmarshalFuncSliceOnly unmarshal,
            SetFunc _setFunc = nullptr);
 
@@ -133,6 +142,8 @@ public:
            const Ptr<LogFieldAliasMap> &map, SetFunc _setFunc = nullptr);
 
   LogField(const char *field, Container container);
+  LogField(const char *symbol, std::vector<HeaderField> header_fields, std::unique_ptr<LogField> fallback_field = nullptr,
+           std::optional<std::string> fallback_default = std::nullopt);
   LogField(const LogField &rhs);
   ~LogField();
 
@@ -188,6 +199,7 @@ public:
   static Container valid_container_name(char *name);
   static Aggregate valid_aggregate_name(char *name);
   static bool      fieldlist_contains_aggregates(const char *fieldlist);
+  static bool      isHeaderContainer(Container container);
   static bool      isContainerUpdateFieldSupported(Container container);
 
 private:
@@ -208,6 +220,17 @@ private:
   TSMilestonesType      milestone_from_m_name();
   int                   milestones_from_m_name(TSMilestonesType *m1, TSMilestonesType *m2);
 
+  std::vector<HeaderField>   m_fallback_header_fields;
+  std::unique_ptr<LogField>  m_fallback_field;
+  std::optional<std::string> m_fallback_default;
+  bool                       is_field_fallback() const;
+  int                        select_fallback_selector(LogAccess *lad) const;
+  unsigned                   marshal_fallback_header_field(LogAccess *lad, const HeaderField &field, char *buf) const;
+  unsigned                   marshal_fallback_default(char *buf) const;
+
+  static constexpr int FALLBACK_DEFAULT_SELECTOR = -1;
+  static constexpr int FALLBACK_FIELD_SELECTOR   = -2;
+
 public:
   LINK(LogField, link);
   LogSlice m_slice;
diff --git a/src/proxy/logging/CMakeLists.txt b/src/proxy/logging/CMakeLists.txt
index 27da4f05d8..29139f69d5 100644
--- a/src/proxy/logging/CMakeLists.txt
+++ b/src/proxy/logging/CMakeLists.txt
@@ -26,6 +26,7 @@ add_library(
   LogFile.cc
   LogFilter.cc
   LogFormat.cc
+  LogFieldFallback.cc
   LogObject.cc
   LogUtils.cc
   RolledLogDeleter.cc
@@ -39,6 +40,11 @@ target_include_directories(logging PRIVATE ${SWOC_INCLUDE_DIR})
 target_link_libraries(logging PUBLIC ts::inkevent ts::inkutils ts::http ts::hdrs ts::tscore yaml-cpp::yaml-cpp)
 
 if(BUILD_TESTING)
+  add_executable(test_LogFieldFallback unit-tests/test_LogFieldFallback.cc)
+  target_include_directories(test_LogFieldFallback PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+  target_link_libraries(test_LogFieldFallback PRIVATE ts::logging Catch2::Catch2WithMain)
+  add_catch2_test(NAME test_LogFieldFallback COMMAND test_LogFieldFallback)
+
   add_executable(test_LogUtils LogUtils.cc unit-tests/test_LogUtils.cc)
   target_compile_definitions(test_LogUtils PRIVATE TEST_LOG_UTILS)
   target_link_libraries(test_LogUtils tscore ts::inkevent records Catch2::Catch2WithMain)
diff --git a/src/proxy/logging/LogAccess.cc b/src/proxy/logging/LogAccess.cc
index 908ec8c22f..8921fbf12f 100644
--- a/src/proxy/logging/LogAccess.cc
+++ b/src/proxy/logging/LogAccess.cc
@@ -473,6 +473,54 @@ LogAccess::marshal_ip(char *dest, sockaddr const *ip)
   return INK_ALIGN_DEFAULT(len);
 }
 
+HTTPHdr *
+LogAccess::header_for_container(LogField::Container container) const
+{
+  switch (container) {
+  case LogField::CQH:
+  case LogField::ECQH:
+    return m_client_request;
+
+  case LogField::PSH:
+  case LogField::EPSH:
+    return m_proxy_response;
+
+  case LogField::PQH:
+  case LogField::EPQH:
+    return m_proxy_request;
+
+  case LogField::SSH:
+  case LogField::ESSH:
+    return m_server_response;
+
+  case LogField::CSSH:
+  case LogField::ECSSH:
+    return m_cache_response;
+
+  default:
+    return nullptr;
+  }
+}
+
+bool
+LogAccess::has_http_header_field(LogField::Container container, const char *field) const
+{
+  if (HTTPHdr const *header = header_for_container(container); header != nullptr) {
+    if (header->field_find(std::string_view{field}) != nullptr) {
+      return true;
+    }
+  }
+
+  if (container == LogField::SSH && strcmp(field, "Transfer-Encoding") == 0) {
+    const std::string &stored_te = m_http_sm->t_state.hdr_info.server_response_transfer_encoding;
+    if (!stored_te.empty()) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
 inline int
 LogAccess::unmarshal_with_map(int64_t code, char *dest, int len, const Ptr<LogFieldAliasMap> &map, const char *msg)
 {
@@ -3225,33 +3273,7 @@ LogAccess::marshal_http_header_field(LogField::Container container, char *field,
   int      padded_len  = INK_MIN_ALIGN;
   int      actual_len  = 0;
   bool     valid_field = false;
-  HTTPHdr *header;
-
-  switch (container) {
-  case LogField::CQH:
-    header = m_client_request;
-    break;
-
-  case LogField::PSH:
-    header = m_proxy_response;
-    break;
-
-  case LogField::PQH:
-    header = m_proxy_request;
-    break;
-
-  case LogField::SSH:
-    header = m_server_response;
-    break;
-
-  case LogField::CSSH:
-    header = m_cache_response;
-    break;
-
-  default:
-    header = nullptr;
-    break;
-  }
+  HTTPHdr *header      = header_for_container(container);
 
   if (header) {
     MIMEField *fld = header->field_find(std::string_view{field});
@@ -3356,33 +3378,7 @@ LogAccess::marshal_http_header_field_escapify(LogField::Container container, cha
   int      padded_len = INK_MIN_ALIGN;
   int      actual_len = 0, new_len = 0;
   bool     valid_field = false;
-  HTTPHdr *header;
-
-  switch (container) {
-  case LogField::ECQH:
-    header = m_client_request;
-    break;
-
-  case LogField::EPSH:
-    header = m_proxy_response;
-    break;
-
-  case LogField::EPQH:
-    header = m_proxy_request;
-    break;
-
-  case LogField::ESSH:
-    header = m_server_response;
-    break;
-
-  case LogField::ECSSH:
-    header = m_cache_response;
-    break;
-
-  default:
-    header = nullptr;
-    break;
-  }
+  HTTPHdr *header      = header_for_container(container);
 
   if (header) {
     MIMEField *fld = header->field_find(std::string_view{field});
diff --git a/src/proxy/logging/LogField.cc b/src/proxy/logging/LogField.cc
index a70aa9240c..ea3dbe8456 100644
--- a/src/proxy/logging/LogField.cc
+++ b/src/proxy/logging/LogField.cc
@@ -28,6 +28,8 @@
  representation of a logging field.
  ***************************************************************************/
 #include "tscore/ink_platform.h"
+#include <cstring>
+#include <utility>
 #include "swoc/swoc_meta.h"
 #include "swoc/TextView.h"
 #include "swoc/string_view_util.h"
@@ -404,6 +406,33 @@ LogField::LogField(const char *field, Container container)
   }
 }
 
+LogField::LogField(const char *symbol, std::vector<HeaderField> header_fields, std::unique_ptr<LogField> fallback_field,
+                   std::optional<std::string> fallback_default)
+  : m_name(ats_strdup(symbol)),
+    m_symbol(ats_strdup(symbol)),
+    m_type(LogField::STRING),
+    m_container(NO_CONTAINER),
+    m_marshal_func(nullptr),
+    m_unmarshal_func(&(LogAccess::unmarshal_str)),
+    m_agg_op(NO_AGGREGATE),
+    m_agg_cnt(0),
+    m_agg_val(0),
+    m_milestone1(TS_MILESTONE_LAST_ENTRY),
+    m_milestone2(TS_MILESTONE_LAST_ENTRY),
+    m_time_field(false),
+    m_alias_map(nullptr),
+    m_set_func(nullptr),
+    m_fallback_header_fields(std::move(header_fields)),
+    m_fallback_field(std::move(fallback_field)),
+    m_fallback_default(std::move(fallback_default))
+{
+  ink_assert(m_name != nullptr);
+  ink_assert(m_symbol != nullptr);
+  ink_assert(m_type >= 0 && m_type < N_TYPES);
+  ink_assert(!m_fallback_header_fields.empty());
+  ink_assert(!(m_fallback_field && m_fallback_default.has_value()));
+}
+
 // Copy ctor
 LogField::LogField(const LogField &rhs)
   : m_name(ats_strdup(rhs.m_name)),
@@ -419,7 +448,10 @@ LogField::LogField(const LogField &rhs)
     m_milestone2(rhs.m_milestone2),
     m_time_field(rhs.m_time_field),
     m_alias_map(rhs.m_alias_map),
-    m_set_func(rhs.m_set_func)
+    m_set_func(rhs.m_set_func),
+    m_fallback_header_fields(rhs.m_fallback_header_fields),
+    m_fallback_field(rhs.m_fallback_field ? std::make_unique<LogField>(*rhs.m_fallback_field) : nullptr),
+    m_fallback_default(rhs.m_fallback_default)
 {
   ink_assert(m_name != nullptr);
   ink_assert(m_symbol != nullptr);
@@ -446,6 +478,19 @@ LogField::~LogField()
 unsigned
 LogField::marshal_len(LogAccess *lad)
 {
+  if (is_field_fallback()) {
+    int selector = select_fallback_selector(lad);
+    int bytes    = INK_MIN_ALIGN;
+    if (selector >= 0) {
+      bytes += marshal_fallback_header_field(lad, m_fallback_header_fields[selector], nullptr);
+    } else if (selector == FALLBACK_FIELD_SELECTOR) {
+      bytes += m_fallback_field->marshal_len(lad);
+    } else {
+      bytes += marshal_fallback_default(nullptr);
+    }
+    return bytes;
+  }
+
   if (m_container == NO_CONTAINER) {
     return (lad->*m_marshal_func)(nullptr);
   }
@@ -509,6 +554,10 @@ LogField::isContainerUpdateFieldSupported(Container container)
 void
 LogField::updateField(LogAccess *lad, char *buf, int len)
 {
+  if (is_field_fallback()) {
+    return;
+  }
+
   if (m_container == NO_CONTAINER) {
     return (lad->*m_set_func)(buf, len);
   } else {
@@ -528,6 +577,25 @@ LogField::updateField(LogAccess *lad, char *buf, int len)
 unsigned
 LogField::marshal(LogAccess *lad, char *buf)
 {
+  if (is_field_fallback()) {
+    int   selector  = select_fallback_selector(lad);
+    int   bytes     = INK_MIN_ALIGN;
+    char *value_buf = buf ? buf + INK_MIN_ALIGN : nullptr;
+
+    if (buf) {
+      LogAccess::marshal_int(buf, selector);
+    }
+
+    if (selector >= 0) {
+      bytes += marshal_fallback_header_field(lad, m_fallback_header_fields[selector], value_buf);
+    } else if (selector == FALLBACK_FIELD_SELECTOR) {
+      bytes += m_fallback_field->marshal(lad, value_buf);
+    } else {
+      bytes += marshal_fallback_default(value_buf);
+    }
+    return bytes;
+  }
+
   if (m_container == NO_CONTAINER) {
     return (lad->*m_marshal_func)(buf);
   }
@@ -616,6 +684,26 @@ LogField::marshal_agg(char *buf)
 unsigned
 LogField::unmarshal(char **buf, char *dest, int len, LogEscapeType escape_type)
 {
+  if (is_field_fallback()) {
+    int64_t   selector = LogAccess::unmarshal_int(buf);
+    LogSlice *slice    = nullptr;
+
+    if (selector >= 0 && selector < static_cast<int64_t>(m_fallback_header_fields.size()) &&
+        m_fallback_header_fields[selector].slice.m_enable) {
+      slice = &m_fallback_header_fields[selector].slice;
+    }
+
+    if (selector >= 0 && selector < static_cast<int64_t>(m_fallback_header_fields.size())) {
+      return LogAccess::unmarshal_str(buf, dest, len, slice, escape_type);
+    }
+
+    if (selector == FALLBACK_FIELD_SELECTOR && m_fallback_field) {
+      return m_fallback_field->unmarshal(buf, dest, len, escape_type);
+    }
+
+    return LogAccess::unmarshal_str(buf, dest, len, nullptr, escape_type);
+  }
+
   return std::visit(
     swoc::meta::vary{[&](UnmarshalFuncWithSlice f) -> unsigned { return (*f)(buf, dest, len, &m_slice, escape_type); },
                      [&](UnmarshalFuncWithMap f) -> unsigned { return (*f)(buf, dest, len, m_alias_map); },
@@ -635,6 +723,11 @@ LogField::display(FILE *fd)
 {
   static const char *names[LogField::N_TYPES] = {"sINT", "dINT", "STR", "IP"};
 
+  if (is_field_fallback()) {
+    fprintf(fd, "    %30s %10s %5s\n", m_name, "fallback", names[m_type]);
+    return;
+  }
+
   fprintf(fd, "    %30s %10s %5s\n", m_name, m_symbol, names[m_type]);
 }
 
@@ -742,12 +835,112 @@ LogField::fieldlist_contains_aggregates(const char *fieldlist)
   return false;
 }
 
+bool
+LogField::isHeaderContainer(Container container)
+{
+  switch (container) {
+  case CQH:
+  case PSH:
+  case PQH:
+  case SSH:
+  case CSSH:
+  case ECQH:
+  case EPSH:
+  case EPQH:
+  case ESSH:
+  case ECSSH:
+    return true;
+  default:
+    return false;
+  }
+}
+
 void
 LogField::set_http_header_field(LogAccess *lad, LogField::Container container, char *field, char *buf, int len)
 {
   return lad->set_http_header_field(container, field, buf, len);
 }
 
+bool
+LogField::is_field_fallback() const
+{
+  return !m_fallback_header_fields.empty();
+}
+
+int
+LogField::select_fallback_selector(LogAccess *lad) const
+{
+  for (unsigned i = 0; i < m_fallback_header_fields.size(); ++i) {
+    if (lad->has_http_header_field(m_fallback_header_fields[i].container, m_fallback_header_fields[i].name.c_str())) {
+      return i;
+    }
+  }
+
+  if (m_fallback_field) {
+    return FALLBACK_FIELD_SELECTOR;
+  }
+
+  return FALLBACK_DEFAULT_SELECTOR;
+}
+
+unsigned
+LogField::marshal_fallback_header_field(LogAccess *lad, const HeaderField &field, char *buf) const
+{
+  switch (field.container) {
+  case CQH:
+  case PSH:
+  case PQH:
+  case SSH:
+  case CSSH:
+    return lad->marshal_http_header_field(field.container, const_cast<char *>(field.name.c_str()), buf);
+
+  case ECQH:
+  case EPSH:
+  case EPQH:
+  case ESSH:
+  case ECSSH:
+    return lad->marshal_http_header_field_escapify(field.container, const_cast<char *>(field.name.c_str()), buf);
+
+  default:
+    Note("Invalid container type in fallback field: %d", field.container);
+    if (buf) {
+      int padded_len = LogAccess::padded_strlen(nullptr);
+      LogAccess::marshal_str(buf, nullptr, padded_len);
+      return padded_len;
+    }
+    return LogAccess::padded_strlen(nullptr);
+  }
+}
+
+unsigned
+LogField::marshal_fallback_default(char *buf) const
+{
+  if (!m_fallback_default.has_value()) {
+    int padded_len = LogAccess::padded_strlen(nullptr);
+    if (buf) {
+      LogAccess::marshal_str(buf, nullptr, padded_len);
+    }
+    return padded_len;
+  }
+
+  std::string const &fallback_default = *m_fallback_default;
+  int                running_len      = static_cast<int>(fallback_default.size()) + 1;
+  int                padded_len       = LogAccess::padded_length(running_len);
+  if (buf) {
+    std::memcpy(buf, fallback_default.data(), fallback_default.size());
+    buf[fallback_default.size()] = '\0';
+
+#ifdef DEBUG
+    while (running_len < padded_len) {
+      buf[running_len] = '$';
+      ++running_len;
+    }
+#endif
+  }
+
+  return padded_len;
+}
+
 /*-------------------------------------------------------------------------
   LogFieldList
 
diff --git a/src/proxy/logging/LogFieldFallback.cc b/src/proxy/logging/LogFieldFallback.cc
new file mode 100644
index 0000000000..876ac72206
--- /dev/null
+++ b/src/proxy/logging/LogFieldFallback.cc
@@ -0,0 +1,315 @@
+/** @file
+
+  Helpers for parsing log field fallback expressions.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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.
+ */
+
+#include "LogFieldFallback.h"
+
+#include <cctype>
+#include <string>
+#include <string_view>
+#include <utility>
+
+#include "swoc/TextView.h"
+
+namespace
+{
+constexpr std::string_view FIELD_FALLBACK_SEPARATOR{"??"};
+
+/** Find the next fallback separator outside of quoted default literals.
+ *
+ * This keeps the chain tokenizer from splitting on separator text that is
+ * part of a quoted default value.
+ *
+ * @param[in] text Candidate fallback expression text.
+ * @return Pointer to the first separator occurrence, or @c nullptr if none.
+ */
+constexpr char const *
+find_field_fallback_separator(std::string_view text)
+{
+  char quote  = '\0';
+  bool escape = false;
+
+  // The following logic assumes a 2 character separator. If that changes,
+  // adjust the logic accordingly.
+  static_assert(FIELD_FALLBACK_SEPARATOR.size() == 2, "FIELD_FALLBACK_SEPARATOR must be exactly two characters long");
+
+  for (auto const *spot = text.data(), *limit = text.data() + text.size(); spot < limit; ++spot) {
+    char c = *spot;
+
+    if (quote != '\0') {
+      if (escape) {
+        escape = false;
+      } else if (c == '\\') {
+        escape = true;
+      } else if (c == quote) {
+        quote = '\0';
+      }
+      continue;
+    }
+
+    if (c == '\'' || c == '"') {
+      quote = c;
+      continue;
+    }
+
+    if (*spot == FIELD_FALLBACK_SEPARATOR[0] && (spot + 1) < limit && *(spot + 1) == FIELD_FALLBACK_SEPARATOR[1]) {
+      return spot;
+    }
+  }
+
+  return nullptr;
+}
+
+constexpr bool
+test_find_field_fallback_separator()
+{
+  static_assert(find_field_fallback_separator("") == nullptr);
+  constexpr char const *text1 = "{field}??default";
+  static_assert(find_field_fallback_separator(text1) == text1 + 7);
+  constexpr char const *text2 = "??default";
+  static_assert(find_field_fallback_separator(text2) == text2);
+  constexpr char const *text3 = "{field}??def??ault";
+  static_assert(find_field_fallback_separator(text3) == text3 + 7);
+  constexpr char const *text4 = "{field}??\"def??ault\"";
+  static_assert(find_field_fallback_separator(text4) == text4 + 7);
+  return true;
+}
+
+static_assert(test_find_field_fallback_separator(), "find_field_fallback_separator failed its tests");
+
+void
+set_parse_error(std::string &error, std::string_view symbol, std::string_view detail)
+{
+  error.assign("Invalid log field fallback specification: ");
+  error.append(detail.data(), detail.size());
+  error.append(" in ");
+  error.append(symbol.data(), symbol.size());
+}
+
+bool
+parse_header_fallback_candidate(swoc::TextView term, LogField::HeaderField &field, std::string_view original_symbol,
+                                std::string &error)
+{
+  term.trim_if(isspace);
+  std::string term_text{term};
+
+  if (term_text.empty()) {
+    set_parse_error(error, original_symbol, "empty candidate");
+    return false;
+  }
+
+  if (term_text.front() != '{') {
+    set_parse_error(error, original_symbol, term_text + " is not a header field candidate");
+    return false;
+  }
+
+  term.remove_prefix(1);
+  size_t name_end = term.find('}');
+  if (name_end == swoc::TextView::npos) {
+    set_parse_error(error, original_symbol, "no trailing '}'");
+    return false;
+  }
+
+  swoc::TextView field_name = term.substr(0, name_end);
+  if (field_name.empty()) {
+    set_parse_error(error, original_symbol, "empty header name");
+    return false;
+  }
+
+  swoc::TextView container_text = term.substr(name_end + 1);
+  container_text.trim_if(isspace);
+  std::string container_spec{container_text};
+  LogSlice    slice(container_spec.data());
+  auto        container = LogField::valid_container_name(container_spec.data());
+
+  if (!LogField::isHeaderContainer(container)) {
+    set_parse_error(error, original_symbol, container_spec + " is not a supported header container");
+    return false;
+  }
+
+  field.name.assign(field_name.data(), field_name.size());
+  field.container = container;
+  field.slice     = slice;
+
+  return true;
+}
+
+bool
+parse_field_fallback_symbol(swoc::TextView term, std::string &field_symbol, std::string_view original_symbol, std::string &error)
+{
+  term.trim_if(isspace);
+  if (term.empty()) {
+    set_parse_error(error, original_symbol, "empty candidate");
+    return false;
+  }
+
+  if (term.find('(') != swoc::TextView::npos || term.find(')') != swoc::TextView::npos) {
+    set_parse_error(error, original_symbol, "aggregate expressions are not supported in fallback chains");
+    return false;
+  }
+
+  if (term.find('{') != swoc::TextView::npos || term.find('}') != swoc::TextView::npos) {
+    set_parse_error(error, original_symbol, std::string{term} + " is not a supported fallback term");
+    return false;
+  }
+
+  field_symbol.assign(term.data(), term.size());
+  return true;
+}
+
+bool
+parse_field_fallback_default(swoc::TextView term, std::string &default_value, std::string_view original_symbol, std::string &error)
+{
+  term.trim_if(isspace);
+  if (term.empty()) {
+    set_parse_error(error, original_symbol, "empty default");
+    return false;
+  }
+
+  char quote = term.front();
+  if (quote != '\'' && quote != '"') {
+    set_parse_error(error, original_symbol, std::string{term} + " is not a quoted default literal");
+    return false;
+  }
+
+  default_value.clear();
+  bool escaped = false;
+
+  for (auto const *spot = term.data() + 1, *limit = term.data_end(); spot < limit; ++spot) {
+    char c = *spot;
+
+    if (escaped) {
+      if (c == quote || c == '\\') {
+        default_value.push_back(c);
+      } else {
+        default_value.push_back('\\');
+        default_value.push_back(c);
+      }
+      escaped = false;
+      continue;
+    }
+
+    if (c == '\\') {
+      escaped = true;
+      continue;
+    }
+
+    if (c == quote) {
+      if (spot + 1 != limit) {
+        set_parse_error(error, original_symbol, "trailing characters after default literal");
+        return false;
+      }
+      return true;
+    }
+
+    default_value.push_back(c);
+  }
+
+  set_parse_error(error, original_symbol, "unterminated default literal");
+  return false;
+}
+} // namespace
+
+namespace LogFieldFallback
+{
+bool
+has_fallback(std::string_view symbol)
+{
+  return find_field_fallback_separator(symbol) != nullptr;
+}
+
+std::optional<ParseResult>
+parse(std::string_view symbol, std::string &error)
+{
+  error.clear();
+
+  ParseResult    result;
+  swoc::TextView remaining{symbol};
+
+  while (true) {
+    swoc::TextView term;
+    bool           has_more_terms = false;
+
+    if (auto const *separator = find_field_fallback_separator(remaining); separator != nullptr) {
+      term = swoc::TextView{remaining.data(), static_cast<size_t>(separator - remaining.data())};
+      remaining.remove_prefix((separator - remaining.data()) + FIELD_FALLBACK_SEPARATOR.size());
+      has_more_terms = true;
+    } else {
+      term      = remaining;
+      remaining = swoc::TextView{};
+    }
+
+    term.trim_if(isspace);
+    if (term.empty()) {
+      set_parse_error(error, symbol, "empty candidate");
+      return std::nullopt;
+    }
+
+    if (term.starts_with('"') || term.starts_with('\'')) {
+      if (has_more_terms) {
+        set_parse_error(error, symbol, "default literal must be the final term");
+        return std::nullopt;
+      }
+
+      std::string fallback_default;
+      if (!parse_field_fallback_default(term, fallback_default, symbol, error)) {
+        return std::nullopt;
+      }
+      result.fallback_default = std::move(fallback_default);
+      break;
+    }
+
+    if (term.starts_with('{')) {
+      LogField::HeaderField field;
+      if (!parse_header_fallback_candidate(term, field, symbol, error)) {
+        return std::nullopt;
+      }
+
+      result.header_fields.push_back(std::move(field));
+
+      if (!has_more_terms) {
+        break;
+      }
+    } else {
+      if (has_more_terms) {
+        set_parse_error(error, symbol, "plain field symbols must be the final term");
+        return std::nullopt;
+      }
+
+      std::string fallback_symbol;
+      if (!parse_field_fallback_symbol(term, fallback_symbol, symbol, error)) {
+        return std::nullopt;
+      }
+
+      result.fallback_symbol = std::move(fallback_symbol);
+      break;
+    }
+  }
+
+  if (result.header_fields.empty()) {
+    set_parse_error(error, symbol, "must start with a header field candidate");
+    return std::nullopt;
+  }
+
+  return result;
+}
+} // namespace LogFieldFallback
diff --git a/src/proxy/logging/LogFieldFallback.h b/src/proxy/logging/LogFieldFallback.h
new file mode 100644
index 0000000000..0827d06654
--- /dev/null
+++ b/src/proxy/logging/LogFieldFallback.h
@@ -0,0 +1,60 @@
+/** @file
+
+  Private helper declarations for parsing log field fallback expressions.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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.
+ */
+
+#pragma once
+
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "proxy/logging/LogField.h"
+
+namespace LogFieldFallback
+{
+/** Parsed representation of a log field fallback chain. */
+struct ParseResult {
+  /// Header candidates evaluated from left to right.
+  std::vector<LogField::HeaderField> header_fields;
+  /// Optional final plain field symbol used when no header candidate is present.
+  std::optional<std::string> fallback_symbol;
+  /// Optional quoted default literal used when no header candidate is present.
+  std::optional<std::string> fallback_default;
+};
+
+/** Determine whether a symbol uses log field fallback syntax.
+ *
+ * @param[in] symbol Log field symbol text from a custom format.
+ * @return @c true if @a symbol contains an unquoted fallback separator.
+ */
+bool has_fallback(std::string_view symbol);
+
+/** Parse a log field fallback symbol into header candidates and an optional
+ * final plain symbol or default literal.
+ *
+ * @param[in] symbol Log field symbol text from a custom format.
+ * @param[out] error Receives a human-readable parse error on failure.
+ * @return Parsed fallback state on success, @c std::nullopt on failure.
+ */
+std::optional<ParseResult> parse(std::string_view symbol, std::string &error);
+} // namespace LogFieldFallback
diff --git a/src/proxy/logging/LogFormat.cc b/src/proxy/logging/LogFormat.cc
index 52f07eba7b..feee9696e4 100644
--- a/src/proxy/logging/LogFormat.cc
+++ b/src/proxy/logging/LogFormat.cc
@@ -28,13 +28,18 @@
  ***************************************************************************/
 #include "tscore/ink_config.h"
 
+#include <cctype>
 #include <cstdio>
 #include <cstring>
 #include <cstdlib>
+#include <memory>
+#include <string>
+#include <utility>
 
 #include "tscore/SimpleTokenizer.h"
 #include "tscore/CryptoHash.h"
 
+#include "LogFieldFallback.h"
 #include "proxy/logging/LogUtils.h"
 #include "proxy/logging/LogFile.h"
 #include "proxy/logging/LogField.h"
@@ -55,6 +60,25 @@ DbgCtl dbg_ctl_log_format{"log-format"};
 DbgCtl dbg_ctl_log_agg{"log-agg"};
 DbgCtl dbg_ctl_log_slice{"log-slice"};
 
+LogField *
+make_regular_field_from_symbol(char *symbol)
+{
+  LogSlice  slice(symbol);
+  LogField *field = Log::global_field_list.find_by_symbol(symbol);
+
+  if (field == nullptr) {
+    return nullptr;
+  }
+
+  auto *copy = new LogField(*field);
+  if (slice.m_enable) {
+    copy->m_slice = slice;
+    Dbg(dbg_ctl_log_slice, "symbol = %s, [%d:%d]", symbol, copy->m_slice.m_start, copy->m_slice.m_end);
+  }
+
+  return copy;
+}
+
 } // end anonymous namespace
 /*-------------------------------------------------------------------------
   LogFormat::setup
@@ -433,6 +457,8 @@ LogFormat::parse_symbol_string(const char *symbol_string, LogFieldList *field_li
   symbol  = strtok_r(sym_str, ",", &saveptr);
 
   while (symbol != nullptr) {
+    std::string original_symbol(symbol);
+
     //
     // See if there is an aggregate operator, which will contain "()"
     //
@@ -475,6 +501,40 @@ LogFormat::parse_symbol_string(const char *symbol_string, LogFieldList *field_li
              "')' in %s",
              symbol);
       }
+    } else if (LogFieldFallback::has_fallback(symbol)) {
+      Dbg(dbg_ctl_log_format, "Field fallback symbol: %s", symbol);
+      std::string parse_error;
+      auto        parsed = LogFieldFallback::parse(symbol, parse_error);
+      if (parsed.has_value()) {
+        std::unique_ptr<LogField> fallback_field;
+        bool                      valid_fallback = true;
+
+        if (parsed->fallback_symbol.has_value()) {
+          std::string fallback_symbol = *parsed->fallback_symbol;
+          LogField   *resolved_field  = make_regular_field_from_symbol(fallback_symbol.data());
+
+          if (resolved_field == nullptr) {
+            Note("The fallback field symbol %s was not found in the "
+                 "list of known symbols.",
+                 fallback_symbol.c_str());
+            field_list->addBadSymbol(original_symbol);
+            valid_fallback = false;
+          } else {
+            fallback_field.reset(resolved_field);
+          }
+        }
+
+        if (valid_fallback) {
+          f = new LogField(original_symbol.c_str(), std::move(parsed->header_fields), std::move(fallback_field),
+                           std::move(parsed->fallback_default));
+          field_list->add(f, false);
+          field_count++;
+          Dbg(dbg_ctl_log_format, "Field fallback field %s added", original_symbol.c_str());
+        }
+      } else {
+        Note("%s", parse_error.c_str());
+        field_list->addBadSymbol(original_symbol);
+      }
     }
     //
     // Now check for a container field, which starts with '{'
@@ -513,23 +573,17 @@ LogFormat::parse_symbol_string(const char *symbol_string, LogFieldList *field_li
     // treat this like a regular field symbol
     //
     else {
-      LogSlice slice(symbol);
       Dbg(dbg_ctl_log_format, "Regular field symbol: %s", symbol);
-      f = Log::global_field_list.find_by_symbol(symbol);
+      f = make_regular_field_from_symbol(symbol);
       if (f != nullptr) {
-        LogField *cpy = new LogField(*f);
-        if (slice.m_enable) {
-          cpy->m_slice = slice;
-          Dbg(dbg_ctl_log_slice, "symbol = %s, [%d:%d]", symbol, cpy->m_slice.m_start, cpy->m_slice.m_end);
-        }
-        field_list->add(cpy, false);
+        field_list->add(f, false);
         field_count++;
         Dbg(dbg_ctl_log_format, "Regular field %s added", symbol);
       } else {
         Note("The log format symbol %s was not found in the "
              "list of known symbols.",
              symbol);
-        field_list->addBadSymbol(symbol);
+        field_list->addBadSymbol(original_symbol);
       }
     }
 
diff --git a/src/proxy/logging/unit-tests/test_LogFieldFallback.cc b/src/proxy/logging/unit-tests/test_LogFieldFallback.cc
new file mode 100644
index 0000000000..74ec47e2aa
--- /dev/null
+++ b/src/proxy/logging/unit-tests/test_LogFieldFallback.cc
@@ -0,0 +1,120 @@
+/** @file
+
+  Catch-based tests for LogFieldFallback parsing helpers.
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you 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.
+ */
+
+#include <string>
+
+#include <catch2/catch_test_macros.hpp>
+
+#include "LogFieldFallback.h"
+
+using LogFieldFallback::ParseResult;
+
+namespace
+{
+ParseResult
+require_parse(std::string_view symbol)
+{
+  std::string error;
+  auto        parsed = LogFieldFallback::parse(symbol, error);
+
+  INFO(error);
+  REQUIRE(parsed.has_value());
+
+  return std::move(*parsed);
+}
+} // namespace
+
+TEST_CASE("Field fallback detection ignores quoted separators", "[LogFieldFallback]")
+{
+  REQUIRE_FALSE(LogFieldFallback::has_fallback("{field}cqh"));
+  REQUIRE(LogFieldFallback::has_fallback("{field}cqh??{other}pqh"));
+  REQUIRE(LogFieldFallback::has_fallback("{field}cqh??chi"));
+  REQUIRE(LogFieldFallback::has_fallback("{field}cqh??\"default??value\""));
+  REQUIRE_FALSE(LogFieldFallback::has_fallback("\"default??value\""));
+}
+
+TEST_CASE("Field fallback parsing preserves containers and slices", "[LogFieldFallback]")
+{
+  auto parsed = require_parse(" {x-primary-id}cqh[0:8] ?? {x-secondary-id}epqh[0:16] ");
+
+  REQUIRE(parsed.header_fields.size() == 2);
+  REQUIRE_FALSE(parsed.fallback_symbol.has_value());
+  REQUIRE_FALSE(parsed.fallback_default.has_value());
+
+  CHECK(parsed.header_fields[0].name == "x-primary-id");
+  CHECK(parsed.header_fields[0].container == LogField::CQH);
+  CHECK(parsed.header_fields[0].slice.m_enable);
+  CHECK(parsed.header_fields[0].slice.m_start == 0);
+  CHECK(parsed.header_fields[0].slice.m_end == 8);
+
+  CHECK(parsed.header_fields[1].name == "x-secondary-id");
+  CHECK(parsed.header_fields[1].container == LogField::EPQH);
+  CHECK(parsed.header_fields[1].slice.m_enable);
+  CHECK(parsed.header_fields[1].slice.m_start == 0);
+  CHECK(parsed.header_fields[1].slice.m_end == 16);
+}
+
+TEST_CASE("Field fallback parsing supports final plain symbols", "[LogFieldFallback]")
+{
+  auto parsed = require_parse("{x-primary-id}cqh??{x-secondary-id}cqh??cqup[0:7]");
+
+  REQUIRE(parsed.header_fields.size() == 2);
+  REQUIRE(parsed.fallback_symbol.has_value());
+  CHECK(parsed.fallback_symbol == std::optional<std::string>{"cqup[0:7]"});
+  REQUIRE_FALSE(parsed.fallback_default.has_value());
+}
+
+TEST_CASE("Field fallback parsing supports quoted defaults", "[LogFieldFallback]")
+{
+  auto parsed = require_parse("{x-primary-id}cqh??{x-secondary-id}cqh??\"missing\\\"id\\\\tail\"");
+
+  REQUIRE(parsed.header_fields.size() == 2);
+  REQUIRE_FALSE(parsed.fallback_symbol.has_value());
+  REQUIRE(parsed.fallback_default.has_value());
+  CHECK(parsed.fallback_default == std::optional<std::string>{"missing\"id\\tail"});
+}
+
+TEST_CASE("Field fallback parsing rejects malformed chains", "[LogFieldFallback]")
+{
+  std::string error;
+
+  auto parsed = LogFieldFallback::parse("{x-primary-id}cqh??\"missing\"??{x-secondary-id}cqh", error);
+  REQUIRE_FALSE(parsed.has_value());
+  CHECK(error.find("default literal must be the final term") != std::string::npos);
+
+  parsed = LogFieldFallback::parse("{x-primary-id}record??{x-secondary-id}cqh", error);
+  REQUIRE_FALSE(parsed.has_value());
+  CHECK(error.find("record is not a supported header container") != std::string::npos);
+
+  parsed = LogFieldFallback::parse("{x-primary-id}cqh????{x-secondary-id}cqh", error);
+  REQUIRE_FALSE(parsed.has_value());
+  CHECK(error.find("empty candidate") != std::string::npos);
+
+  parsed = LogFieldFallback::parse("{x-primary-id}cqh??chi??\"missing\"", error);
+  REQUIRE_FALSE(parsed.has_value());
+  CHECK(error.find("plain field symbols must be the final term") != std::string::npos);
+
+  parsed = LogFieldFallback::parse("{x-primary-id}cqh??SUM(psql)", error);
+  REQUIRE_FALSE(parsed.has_value());
+  CHECK(error.find("aggregate expressions are not supported in fallback chains") != std::string::npos);
+}
diff --git a/tests/gold_tests/logging/gold/field-test.gold b/tests/gold_tests/logging/gold/field-test.gold
index c33b71f4ba..8f1e4c7375 100644
--- a/tests/gold_tests/logging/gold/field-test.gold
+++ b/tests/gold_tests/logging/gold/field-test.gold
@@ -1,3 +1,3 @@
-Transfer-Encoding:Chunked Content-Type:application/json,%20application/json
-Transfer-Encoding:- Content-Type:application/jason,%20application/json
-Transfer-Encoding:- Content-Type:application/json
+Transfer-Encoding:Chunked Content-Type:application/json,%20application/json Request-ID:primary-1 Request-ID-Default:primary-1 Request-ID-Or-IP:primary-1 Remote-IP:203.0.113.10 Path-Fallback:test-1
+Transfer-Encoding:- Content-Type:application/jason,%20application/json Request-ID:secondary-2 Request-ID-Default:secondary-2 Request-ID-Or-IP:secondary-2 Remote-IP:127.0.0.1 Path-Fallback:test-2
+Transfer-Encoding:- Content-Type:application/json Request-ID:- Request-ID-Default:missing-id Request-ID-Or-IP:127.0.0.1 Remote-IP:127.0.0.1 Path-Fallback:test-3
diff --git a/tests/gold_tests/logging/log-field.test.py b/tests/gold_tests/logging/log-field.test.py
index e7534850c3..55e5003a6e 100644
--- a/tests/gold_tests/logging/log-field.test.py
+++ b/tests/gold_tests/logging/log-field.test.py
@@ -73,7 +73,7 @@ ts.Disk.logging_yaml.AddLines(
 logging:
   formats:
     - name: custom
-      format: 'Transfer-Encoding:%<{Transfer-Encoding}ssh> Content-Type:%<{Content-Type}essh>'
+      format: 'Transfer-Encoding:%<{Transfer-Encoding}ssh> Content-Type:%<{Content-Type}essh> Request-ID:%<{X-Primary-Id}cqh??{X-Secondary-Id}cqh> Request-ID-Default:%<{X-Primary-Id}cqh??{X-Secondary-Id}cqh??"missing-id"> Request-ID-Or-IP:%<{X-Primary-Id}cqh??{X-Secondary-Id}cqh??chi> Remote-IP:%<{X-Remote-Ip}cqh??chi> Path-Fallback:%<{X-Path-Fallback}cqh??cqup[0:7]>'
   logs:
     - filename: field-test
       format: custom
@@ -95,15 +95,21 @@ tr.Processes.Default.StartBefore(nameserver)
 # Delay on readiness of our ssl ports
 tr.Processes.Default.StartBefore(Test.Processes.ts)
 
-tr.MakeCurlCommand('--verbose --header "Host: test-1" http://localhost:{0}/test-1'.format(ts.Variables.port), ts=ts)
+tr.MakeCurlCommand(
+    '--verbose --ipv4 --header "Host: test-1" --header "X-Primary-Id: primary-1" --header "X-Secondary-Id: secondary-1" --header "X-Remote-Ip: 203.0.113.10" http://127.0.0.1:{0}/test-1'
+    .format(ts.Variables.port),
+    ts=ts)
 tr.Processes.Default.ReturnCode = 0
 
 tr = Test.AddTestRun()
-tr.MakeCurlCommand('--verbose --header "Host: test-2" http://localhost:{0}/test-2'.format(ts.Variables.port), ts=ts)
+tr.MakeCurlCommand(
+    '--verbose --ipv4 --header "Host: test-2" --header "X-Secondary-Id: secondary-2" http://127.0.0.1:{0}/test-2'.format(
+        ts.Variables.port),
+    ts=ts)
 tr.Processes.Default.ReturnCode = 0
 
 tr = Test.AddTestRun()
-tr.MakeCurlCommand('--verbose --header "Host: test-3" http://localhost:{0}/test-3'.format(ts.Variables.port), ts=ts)
+tr.MakeCurlCommand('--verbose --ipv4 --header "Host: test-3" http://127.0.0.1:{0}/test-3'.format(ts.Variables.port), ts=ts)
 tr.Processes.Default.ReturnCode = 0
 
 # Wait for log file to appear, then wait one extra second to make sure TS is done writing it.