                   -----------------------------------------
                    HAProxy OTel filter configuration guide
                               Version 2.3.0
                        ( Last update: 2026-09-14 )
                   -----------------------------------------
                         Author : Miroslav Zagorac
                   Contact : mzagorac at haproxy dot com


SUMMARY
--------

  1.    Overview
  2.    HAProxy filter declaration
  3.    OTel configuration file structure
  3.1.    OTel scope (top-level)
  3.2.    "otel-instrumentation" section
  3.3.    "otel-scope" section
  3.4.    "otel-group" section
  4.    YAML configuration file
  4.1.    Exporters
  4.2.    Samplers
  4.3.    Processors
  4.4.    Readers
  4.5.    Providers
  4.6.    Signals
  5.    HAProxy rule integration
  6.    Complete examples
  6.1.    Standalone example (sa)
  6.2.    Frontend / backend example (fe/be)
  6.3.    Context propagation example (ctx)
  6.4.    TCP-mode example (tcp)
  6.5.    Comparison example (cmp)
  6.6.    Up-down counter example (updown)
  6.7.    Error-logging example (err)
  6.8.    Empty / minimal example (empty)


1. Overview
------------

The OTel filter configuration consists of two files:

  1) An OTel configuration file (.cfg) that defines the tracing model: scopes,
     groups, spans, attributes, events, instrumentation and log-records.

  2) A YAML configuration file (.yml) that configures the OpenTelemetry SDK
     pipeline: exporters, samplers, processors, readers, providers and signal
     routing.

The OTel configuration file is referenced from the HAProxy configuration using
the 'filter opentelemetry' directive.  The YAML file is in turn referenced from
the OTel configuration file using the 'config' keyword inside the
"otel-instrumentation" section.


2. HAProxy filter declaration
------------------------------

The filter is activated by adding a filter directive in the HAProxy
configuration, in a proxy section (frontend / listen / backend); the section
type decides which events the filter sees, as the 'otel-event' keyword tells:

  frontend my-frontend
    ...
    filter opentelemetry [id <id>] config <otel-cfg-file> [<name>]
    ...

If no filter id is specified, 'otel-filter' is used as default.  The 'config'
parameter is mandatory and specifies the path to the OTel configuration file.
An optional section name may follow the file path: it selects the top-level
OTel scope of the configuration file to use (see section 3.1) and defaults to
the filter id.  Since filter ids must be unique, the section name is what lets
several filters read the same OTel scope: each filter keeps its own id and names
one shared scope instead of holding a private copy of it per filter id.
Neither the filter id nor the section name may be the word 'if' or 'unless':
the two words are reserved for conditions.  Each of 'id' and 'config' stands
once on the line, and at most one name follows the file; a repeated keyword
or a further word is a configuration error.  A line without an id is accepted
with a warning.

Note: the parser does not limit the number of OTel filters; it only requires
each filter id to be unique.  Since the wrapper version 3.0.0, every filter
instance holds its own library context, so several filters, each with its own
configuration, may operate simultaneously in one HAProxy process.

Example (from test/sa/haproxy.cfg):

  frontend otel-test-sa-frontend
      bind *:10080
      default_backend servers-backend

      acl acl-http-status-ok status 100:399

      filter opentelemetry id otel-test-sa config sa/otel.cfg

      http-response otel-group otel-test-sa http_response_group if acl-http-status-ok
      http-after-response otel-group otel-test-sa http_after_response_group if !acl-http-status-ok

  backend servers-backend
      server server-1 127.0.0.1:8000

The filter attaches to both HTTP-mode and TCP-mode proxies.  On an HTTP proxy it
runs HTX-level analysis, so all events and HTTP sample fetches are available.  A
TCP proxy carries no HTTP message, so the HTTP-phase events and HTTP sample
fetches do not fire; the connection-lifecycle and tcp-request / tcp-response
content events still do, and section 3.3 lists the full set that fires in TCP
mode.  In place of the HTTP fetches, the forwarded payload is counted and
exposed through the 'otel.bytes_in' and 'otel.bytes_out' sample fetches (see
section 5), so a span opened at connect and finished at close records both the
connection lifetime and the byte volume transferred.  A scope bound to an
HTTP-only event on a non-HTTP proxy is reported as a warning at config load.


3. OTel configuration file structure
--------------------------------------

The OTel configuration file uses a simple section-based format.  It contains
a mandatory "otel-instrumentation" section, zero or more "otel-scope" sections,
and zero or more "otel-group" sections.

The words 'if' and 'unless' introduce conditions and are reserved: no name in
the file, of whatever kind, may be written as either word.  An ACL may not be
called 'or' either, whatever its case, since that word joins the terms of a
condition.  A name is limited in length too, to 127 characters, every key and
variable name included, a variable counted with its scope prefix and the key
inside a 'set-var-ctx' field left aside, which follows the W3C limits given
with that keyword, and a longer one is refused with 'name too long'.  The limit
reaches neither an ACL name, an event name, an exception type, the description
of an 'instrument' nor the text of a sample expression, which the filter keeps
as written, and neither the filter id, the context name on the 'config' line nor
the name of the top-level scope is measured.

Whatever a keyword allows across its lines, a clause of it stands once on a
line: a second 'time' on a 'log-record', a second 'unit' on an 'instrument' or
a second 'root' on a 'span' is a configuration error.  So is a name or a key
written twice in one list, an 'attr' key, a 'link' target or an 'unset-var'
variable among them; the same name may still stand in two lists of one line, as
a span's 'parent' and its inline 'link'.


3.1. OTel scope (top-level)
-----------------------------

The file is organized into top-level OTel scopes, each identified by a name
enclosed in square brackets.  A filter uses the scope whose name matches the
section name given after the configuration file on its 'filter opentelemetry'
line, or, when no section name is given there, its own filter id.

An OTel scope name is unique: opening a scope whose name was already used is a
configuration error.  The whole configuration lives inside OTel scopes, so the
file begins with a scope line; a section or keyword outside of any OTel scope
is a configuration error.  The filter reads only the scope its line selects and
checks the rules of this document in that one; of the others it reads the name
alone, so an unread scope may hold anything from its first section header on
until a filter line selects it; a line above every header is an error HAProxy
itself reports.

  [<name>]
      otel-instrumentation <name>
          ...

      otel-group <name>
          ...

      otel-scope <name>
          ...

Multiple OTel scopes (for different filter instances) can coexist in the same
file:

  [my-first-filter]
      otel-instrumentation instr1
          ...

  [my-second-filter]
      otel-instrumentation instr2
          ...

A single OTel scope can also serve several filter instances at once: each filter
declares its own id and names the shared scope on its filter line:

  filter opentelemetry id flt-1 config otel.cfg my-first-filter
  filter opentelemetry id flt-2 config otel.cfg my-first-filter

Two filters of one proxy cannot share a scope that injects a context: they run
on the same streams and would both write the same headers on one request, so
the resolved inject names stay unique across the OTel filters of a proxy.  A
frontend's and a backend's filter meet on the streams routed between them as
well; the parser cannot see that pairing, so those names are kept apart by the
writer.  Filters that never run on one stream may reuse the names freely, the
injected variables included: everything the filter writes stays bound to the
stream, and the variables carry the transaction scope.

Several OTel scopes may coexist in a single configuration file, each declared
filter selecting and running one of them.  Since the wrapper version 3.0.0,
every filter instance holds its own library context, so several filters may
operate simultaneously in one HAProxy process, each with its own scope.


3.2. "otel-instrumentation" section
-------------------------------------

Exactly one "otel-instrumentation" section must be defined per OTel scope; a
second one is a configuration error whatever its name.  It configures the global
behavior of the filter and declares which groups and scopes are active.

Unless stated otherwise, each keyword of this section may be given only once
and a repetition is a configuration error.  The 'groups', 'scopes' and 'acl'
keywords may be repeated, every name that they declare being unique, and each
'option' may likewise be set only once.  An 'option' line also takes the HAProxy
keyword prefixes 'no' and 'default', which keep the meaning section 4.1 of the
HAProxy Configuration Manual gives them; a prefixed line counts as the one use
the option is allowed.

Syntax:

  otel-instrumentation <name>

Keywords (mandatory):

  config <file> [context]
      Path to the YAML configuration file for the OpenTelemetry SDK and, as an
      optional second argument, the name of the signals context to select within
      that file.  Without the argument, the 'default' context is served.  The
      file is parsed already when the configuration is loaded: 'haproxy -c'
      rejects a broken file.  A context name that matches no signal entry falls
      back to the 'default' entry of each signal, with a warning at startup; a
      signal with no 'default' entry has nothing to fall back to, so 'haproxy
      -c' rejects the name.  A trailing 'if'/'unless' condition is not accepted.

Keywords (optional):

  acl <aclname> <criterion> [flags] [operator] <value> ...
      Declare an ACL.  See section 7 of the HAProxy Configuration Manual.
      Everything after the criterion is pattern text, so an 'if'/'unless' there
      is taken as a pattern value and not as a condition.  The name may be used
      in the conditions of every otel-scope, once the section has defined it; an
      otel-instrumentation placed below an otel-scope comes too late for that
      scope.

  debug-level <value>
      Set the debug level bitmask (e.g. 0x77f).  Only effective when compiled
      with OTEL_DEBUG=1; any other build accepts the line with a warning that
      the keyword is ignored.  The value is checked in every build, so the line
      parses the same with and without the debug build.

  groups <name> ...
      Declare one or more "otel-group" sections used by this instrumentation.
      Every name must be a defined section and is unique in the instrumentation;
      a repeat is a configuration error.  Every defined section must stand here
      as well: one that no 'groups' line names is a configuration error too.
      Can be repeated on multiple lines.  A trailing 'if'/'unless' condition is
      not accepted.

  log global
  log <addr> [len <len>] [format <fmt>] [sample <ranges>:<size>]
      [profile <prof>] <facility> [<level> [<minlevel>]]
  no log
      Enable per-instance logging and set its destination.  The filter reports
      runtime data-path problems here: hard-error episodes at level err and
      swallowed soft errors at level warning.  Each message is edge-triggered
      and rate-limited per instance, so a fault recurring on every stream does
      not flood the log; the 'runtime err' counters in 'flt-otel status' hold
      the true tally.  Without a 'log' line the filter emits nothing at runtime.
      HAProxy allows the 'no' and the 'default' prefix on this keyword; 'no
      log' leaves the filter's own logging off, 'default log' works like a plain
      line, and either one uses up the single line the section allows.

  option disabled / no option disabled
  default option disabled
      Disable or enable the filter.  Default: enabled.

  option dontlog-normal / no option dontlog-normal
  default option dontlog-normal
      Drop the below-error log lines (warnings and informational) while keeping
      the error-level messages.  Default: disabled (all levels are logged).

  option hard-errors / no option hard-errors
  default option hard-errors
      Stop all filter processing in a stream after the first error.  Default:
      disabled (errors are non-fatal).

      In the default soft-error mode a failure affects only the output that
      produced it.  A span attribute, event or baggage sample that fails to
      evaluate is skipped and the span is still exported with the remaining
      data; the same holds for log-record attributes.  A metric measurement is
      dropped whole when its value or any of its attributes fails.  A log record
      whose body fails to evaluate is not emitted.  A span whose parent cannot
      be resolved is not created, and none of its samples are evaluated.

  option noflush / no option noflush
  default option noflush
      Drop the telemetry still buffered in the exporters when the filter is
      deinitialized, instead of flushing it.  The final flush waits for the
      exporters, so an endpoint that is unreachable at that moment delays a
      shutdown or a reload; with this option the teardown returns at once and
      the pending telemetry is lost.  Default: disabled (buffered telemetry
      is flushed on shutdown).

  option require-context / no option require-context
  default option require-context
      Suppress all telemetry for a stream until a valid upstream trace context
      is extracted from it; a stream that never yields a valid one stays silent.
      Some used scope has to carry an 'extract', and a used scope bound to an
      event that runs before the request context can be read, on-stream-start,
      on-client-session-start, on-frontend-tcp-request, on-http-wait-request or
      on-idle-timeout, is a configuration error; both rules are checked once the
      whole configuration is known.  Default: disabled (a context-less stream
      starts a new trace).

  rate-limit <value>
      Percentage of streams for which the filter is activated.  Floating-point
      value from 0.0 to 100.0.  Default: 100.0.  A value below 100.0 is accepted
      with a warning: not every stream then starts a trace, so a span context
      does not always reach a downstream HAProxy.

  scopes <name> ...
      Declare one or more "otel-scope" sections used by this instrumentation.
      Every name must be a defined section and is unique in the instrumentation;
      a repeat is a configuration error.  Can be repeated on multiple lines.  A
      trailing 'if'/'unless' condition is not accepted.  A defined section that
      neither a 'scopes' line nor a declared otel-group names is reported as
      unused, with a warning at load.


Example (from test/sa/otel.cfg):

  [otel-test-sa]
      otel-instrumentation otel-test-instrumentation
          debug-level 0x77f
          log localhost:514 local7 debug
          config sa/otel.yml
          option dontlog-normal
          option hard-errors
          no option disabled
          rate-limit 100.0

          groups http_response_group
          groups http_after_response_group

          scopes skip_health_path

          scopes on_stream_start
          scopes on_stream_stop
          scopes on_idle_timeout

          scopes client_session_start
          scopes frontend_tcp_request
          ...
          scopes server_session_end


3.3. "otel-scope" section
---------------------------

An "otel-scope" section defines the actions that take place when a particular
event fires or when a group is triggered.

A keyword that names a span or an extracted context -- the 'parent' and the
inline 'link' of a span, the standalone 'link', 'finish', 'set-var-ctx' and the
'span' of a 'log-record', which takes a span alone -- refers to a name that
some otel-scope defines, this one or another.  The name is resolved at run time
against the spans and the contexts the stream has opened by then, so a name
that no otel-scope defines anywhere can never resolve and is refused when the
configuration is checked.

Syntax:

  otel-scope <name>

Supported keywords:

  span <name> [parent <ref>] [link <ref>] [root] [kind <kind>]
      Create a new span or reference an already opened one.

      - 'root' declares the span as the root span; the mark changes nothing
        at run time and nothing about the trace itself: a marked span and an
        unmarked one are the same kind of span.  Only one span name may carry
        the mark, a second name marked 'root' is a configuration error; the
        same name may carry it again in another otel-scope as an alternative
        creation.  Leaving every span unmarked is not an error, only a warning
        at load, and only the spans of the scopes that run count towards it.
        The 'parent' of a marked span may name an extracted context, the remote
        parent of the trace, never a span.
      - 'parent <ref>' sets the parent to an existing span or extracted context
        name.  A reference that resolves to no span or extracted context -- for
        instance a 'parent' on a context whose extraction found nothing -- is
        an error, and the span is not created.  A span cannot be its own parent,
        since the reference is looked up before the span exists.
      - 'link <ref>' adds an inline link to another span or context.  Only one
        may stand on the line, a second one is a configuration error, like a
        repeated 'parent', 'root' or 'kind'.  Use the standalone 'link' keyword
        to add more.
      - 'kind <kind>' sets the span kind to one of the following: 'internal',
        'server' (default), 'client', 'producer' or 'consumer'.
      - Without a 'parent' the span has no local parent and starts a trace of
        its own, whether or not it carries the 'root' mark above.

      A span line opens a "sub-context" within the scope: the keywords 'link',
      'attribute', 'event', 'baggage', 'inject', 'status' and 'exception' that
      follow apply to that span until the next 'span' keyword or the end of the
      scope.  A later bare 'span <name>' naming an already created span makes
      it the active one again, so further operations can be attached to it;
      repeating the definition with arguments in the same scope is an error.
      Another otel-scope may carry a defining line of the same name, which the
      parser accepts because it cannot know which scope runs first: at run time
      exactly one line creates the span, and a defining line of another scope
      that finds it already created is an error, not a line without effect.
      A span may not be called '*', '*req*' or '*res*', the wildcard names
      'finish' keeps for whatever the stream holds.

      Examples:
        span "HAProxy session" root
        span "Client session" parent "HAProxy session"
        span "HTTP request" parent "TCP request" link "HAProxy session"
        span "Client session" parent "otel_ctx_1"
        span "Backend request" parent "Client session" kind client


  attribute <key> <sample> ... [{ if | unless } <condition>]
      Set an attribute on the currently active span.  A single sample preserves
      its native type; multiple samples are concatenated as a string.

      An optional trailing 'if'/'unless' ACL condition covers the attribute: it
      is set only when the condition is met at request time.  The keyword may be
      repeated, one line per condition: the lines of one key are tried in the
      configuration order, the first whose condition holds sets that key, and
      a line without a condition, placed last, acts as the key's default.

      Examples:
        attribute "http.method" method
        attribute "http.url" url
        attribute "http.version" str("HTTP/") req.ver
        attribute "error.flag" str("1") if { status ge 500 }


  event <name> [time [<unit>] <sample>] <key> <sample> ... [{ if | unless } <condition>]
      Add a span event (timestamped annotation) to the currently active span.
      The data type is always string.

      - 'time [<unit>] <sample>' overrides the event timestamp.  The optional
        unit is 's' (default), 'ms', 'us' or 'ns', and <sample> must evaluate
        to a number.  Without it, the event takes the time of the scope run that
        adds it, which is the span's own start only in the run that creates the
        span.  Note that a 'time' token directly after <name> is read as this
        clause, so the key cannot be called 'time'.

      An optional trailing 'if'/'unless' ACL condition covers the event.  The
      keyword may be repeated, and every line whose condition holds is applied.
      The lines of one span definition are grouped by <name>: each distinct
      name produces a single span event, and the <key>/<sample> pairs of all
      lines sharing that name become the attributes of that one event.  The
      timestamp is set by the first such line whose 'time' clause evaluates,
      and the later lines keep it.  Within one event a key may be named across
      several lines, one per condition: the first line whose condition holds
      supplies the key, and a line without a condition, placed last for its
      key, acts as the default.  The same key may appear in different events
      independently.

      Examples:
        event "event_ip" "src" src str(":") src_port
        event "event_ip" "dst" dst str(":") dst_port
        event "event_be" "be" be_id str(" ") be_name
        event "received" time ms req.hdr("x-start-ms") "stage" str("recv")


  baggage <key> <sample> ... [{ if | unless } <condition>]
      Set baggage on the currently active span.  Baggage propagates to all child
      spans.  The data type is always string.  The <key> holds letters, digits
      and '_', '.' and '-', the characters a context prefix takes.  A sample
      that yields nothing leaves its pair out, since the carrier refuses an
      empty value and would drop the other pairs of the span with it.

      An optional trailing 'if'/'unless' ACL condition covers the baggage.  The
      keyword may be repeated, one line per condition: the lines of one key are
      tried in the configuration order, the first whose condition holds sets
      that key, and a line without a condition, placed last, acts as the key's
      default.

      Examples:
        baggage "haproxy_id" var(sess.otel.uuid)
        baggage "area" str("api") if { path_beg /api }


  status <code> [<sample> ...] [{ if | unless } <condition>]
      Set the span status.  Valid codes: ignore, unset, ok, error; without a
      'status' directive a span defaults to 'ignore'.  An optional description
      follows the code, but the OTLP exporter keeps the status message only for
      the 'error' code, dropping it for 'ok' and 'unset'.

      An optional trailing 'if'/'unless' ACL condition covers the status.  The
      keyword may be given more than once per span; the lines are tried in
      configuration order and the first whose condition holds sets the status,
      the remaining lines being skipped.  A line without a condition always
      matches, so an unconditional 'status' is the default and comes last, and
      a line after it is a configuration error.  The same first-match scheme
      governs 'attribute', 'baggage', the variable keywords and the create form
      of 'instrument'; 'link', 'exception', 'log-record' and an 'instrument'
      update instead apply every line whose condition holds, and so does
      'event', though the value of a repeated (name, key) pair is first-match
      again.

      Examples:
        status "ok"
        status "error" str("http.status_code: ") status
        status "error" str("server error") if { status ge 500 }


  exception <type> [message <sample> ...] [attr <key> <sample> ...] [{ if | unless } <condition>]
      Record an exception on the currently active span via the OpenTelemetry
      record_exception() operation, which emits a span event named 'exception'
      following the semantic conventions.  <type> is a literal string stored as
      the 'exception.type' attribute.

      The optional 'message' keyword introduces sample expressions whose values
      are concatenated into 'exception.message'.  The repeatable 'attr <key>
      <sample>' clause adds further attributes to the event.  A second 'message'
      clause is a configuration error, and so is a repeated key.  HAProxy has no
      call stack, so 'exception.stacktrace' is never emitted.  An attribute key
      takes a single sample expression.

      Recording an exception does not change the span status, so pair it with a
      'status error' line when the exception marks the span as failed.  An
      optional 'if'/'unless' ACL condition covers the line, and the keyword may
      be repeated to record several exceptions on one span.  All the lines are
      independent: every line whose condition holds records its own exception
      event, the same type included.

      Examples:
        exception upstream_error message str("HTTP ") status
        exception bad_gateway attr "http.route" path if { status ge 500 }


  link { <ref> ... | <ref> attr <key> <sample> ... } [{ if | unless } <condition>]
      Add non-hierarchical links to the currently active span.  Multiple span
      or context names can be specified.  Use this keyword when a link must
      carry attributes, which the inline 'link' in 'span' cannot.

      A single link may carry attributes: when 'attr' follows the one span
      name, the rest of the line is <key> <sample> pairs, each a single sample
      expression, and the keyword may be written again before each pair.  Link
      attributes require this standalone keyword and belong to that one link,
      so 'attr' written after a list of references is rejected.  Because the
      keyword opens the form and separates the pairs, neither a link target
      after the first one nor an attribute key can be the word 'attr'.

      An optional trailing 'if'/'unless' ACL condition guards the link: it is
      added only when the condition is met at request time.  On a line naming
      several references, the condition applies to each of them.  A link target
      cannot be the literal word 'if' or 'unless'; that word always starts the
      condition.

      The keyword may be repeated freely, with or without a condition: every
      line whose condition holds adds its links, so naming the same target on
      several lines adds one link per line.  A target repeated within a single
      line is a configuration error, and so is a repeated attribute key.

      Examples:
        link "HAProxy session" "Client session"
        link "HAProxy session" attr "link.kind" str("follows_from")
        link "HAProxy session" "Client session" unless { path_beg /health }


  inject <name-prefix> [use-vars] [use-headers]
      Inject span context into an HTTP header carrier and/or HAProxy variables.
      The prefix names the context and holds letters, digits and '_', '.' and
      '-' alone; the special prefix '-' generates the name automatically.  An
      autogenerated name keeps a leading '-', so the injected headers carry only
      the bare propagation headers (see below).  Default storage: use-headers.
      The 'use-vars' option requires OTEL_USE_VARS=1 at compile time.

      A span carries a single context, so 'inject' may appear at most once per
      span of one otel-scope; a second in the same scope is a configuration
      error, while a span re-activated in another otel-scope may carry a second
      context there.  The resolved context name is in addition one that no other
      'inject' of the configuration or of the other OTel filters of the proxy
      resolves to, two names differing only in case counting as one because both
      carriers keep the name in lower case, and at most one context on the proxy
      may inject the nameless headers, those of a name with the leading '-'.  A
      second one of either kind is a configuration error.

      Header injection (use-headers) works only on events that expose a writable
      HTTP header carrier, and only on an HTTP-mode proxy; using it on any other
      event, or on a 'mode tcp' proxy, is a configuration error.  It is rejected
      for these events:

        on-stream-start, on-stream-stop, on-idle-timeout, on-backend-set,
        on-client-session-end, on-server-unavailable, on-server-session-start,
        on-tcp-response, on-http-end-request, on-http-end-response,
        on-http-reply, on-server-session-end

      A scope that carries no 'otel-event' at all is rejected the same way: it
      runs wherever the 'otel-group' action that reaches it stands, which the
      OTel configuration file cannot know.  Such a scope can therefore inject
      the context into variables only.

      Variable injection (use-vars) has no such restriction.  The variables it
      generates carry the context name with its letters lowercased and every
      '-' mapped to 'D', as HAProxy allows neither in a variable name; a name
      holding an upper-case letter or a '-', which every autogenerated name
      does, is accepted with a warning about the mapping.

      When transferring the context via HTTP headers, if the first character of
      the context name is '-', the context name is excluded from the header.
      This enables the use of OpenTelemetry contexts with external services that
      do not expect a HAProxy-specific name prefix.

      Independently of 'inject', a build with OTEL_USE_VARS=1 registers the
      HAProxy variable 'sess.otel.uuid' on every stream the filter attaches to
      and stores the stream's runtime-context UUID in it; several examples read
      it through 'var(sess.otel.uuid)'.  Without this build option the variable
      does not exist and the fetch yields an empty value.

      Example:
        span "HAProxy session" root
            inject "otel_ctx_1" use-headers use-vars


  extract <name-prefix> [use-vars | use-headers]
      Extract a previously injected span context from an HTTP header or HAProxy
      variables.  The extracted context can then be used as a parent reference
      in 'span ... parent <name-prefix>'.  Default storage: use-headers.  The
      'use-vars' option requires OTEL_USE_VARS=1 at compile time.  A stream
      that carries no matching context data is not an error -- the extraction is
      simply skipped.  A span that then names the missing context as its parent
      fails to resolve the reference and is not created.  A header whose name is
      only the prefix itself is ignored, as stripping the prefix would leave an
      empty data name.  No span may carry the context name: a reference resolves
      to the spans first, so the context could never be reached.  A prefix is
      unique within its scope and a repetition is a configuration error, while
      another otel-scope may reuse the name.  The prefix takes the characters
      an 'inject' prefix does.

      Header extraction (use-headers) needs a channel to read from, so it is a
      configuration error on on-stream-start and on-stream-stop, the two events
      that fire with no channel at all, and it needs an HTTP-mode proxy, the
      no-event scope included.  A scope that carries no 'otel-event' is not
      affected by the event rule, because the 'otel-group' action that runs
      it always has a channel.  Variable extraction (use-vars) carries no such
      restriction, and a prefix with an upper-case letter or a '-' draws the
      same variable-name warning as under 'inject'.

      When extracting context from HTTP headers, if the first character of the
      context name is '-', the context name is excluded from the header.  This
      enables the use of OpenTelemetry contexts with external services that do
      not expect a HAProxy-specific name prefix.

      Example:
        extract "otel_ctx_1" use-vars
        span "Client session" parent "otel_ctx_1"


  finish <name> ...
      Close one or more spans or span contexts.  Special names:
        '*'      - finish all open spans and contexts
        '*req*'  - finish all request-channel spans and contexts
        '*res*'  - finish all response-channel spans and contexts

      Multiple names can be given on one line; each is matched independently
      against the open spans and contexts by exact name.  The special names
      above stand for whatever the stream holds, so they are exempt from the
      name check that the other ones undergo.  A name may be given only once
      in the scope, whether on one line or on several, and a repetition is a
      configuration error; another otel-scope may finish the same span.  The
      keyword does not accept a trailing 'if'/'unless' condition; one given
      after the names is rejected.

      Examples:
        finish "Frontend TCP request"
        finish "Client session" "otel_ctx_2"
        finish *


  instrument <type> <name> [aggr <aggregation>] [desc <description>] [unit <unit>] value <sample> [bounds <bounds>] [{ if | unless } <condition>]
  instrument update <name> [attr <key> <sample> ...] [{ if | unless } <condition>]
      Create or update a metric instrument.  For the create form, the 'aggr',
      'desc', 'unit', 'value' and 'bounds' keywords may be given in any order.
      A create-form instrument name may repeat, in the same or another scope:
      the lines of one name share one instrument, so they must agree on the
      type, the aggregation, the description, the unit and the bounds, and a
      disagreement is a configuration error, while the value and the condition
      are what the repeated lines vary.  Within one scope the create lines of a
      name follow the first-match rule, one line per condition with the bare
      line last as the default: an update form records the value of the first
      line whose condition holds, taken from the chain of its own scope; without
      one there it follows the scope that created the instrument, and until it
      exists, the first scope of the file that runs and defines the name.  An
      update that runs needs such a scope, so it is refused when every create
      line of the name stands in a scope that never runs; the create lines alone
      record nothing and only draw the warning of the scope that holds them.  At
      run time exactly one otel-scope ever creates the instrument, and a create
      line of another otel-scope that finds it already created is a runtime
      error, not a line without effect.  The update form is additive, like
      'log-record': it may repeat freely, and every update line whose condition
      holds records one measurement of its own.  Its 'attr' keyword may stand
      once before the first <key> <sample> pair or again before each of them, so
      no key may be called 'attr', and every key takes a single sample
      expression.  The 'value' of a create form likewise takes one and refuses a
      second.  Names compare case-insensitively.  A name begins with a letter
      and carries letters, digits and the punctuation '_', '.', '-' and '/'
      alone, and a unit is at most 63 ASCII characters: the metric SDK refuses
      anything else, and an instrument it refuses records nothing at all.

      Supported types:
        cnt_int   - counter (uint64)
        hist_int  - histogram (uint64)
        udcnt_int - up-down counter (int64)
        gauge_int - gauge (int64)

      Supported aggregation types:
        drop           - measurements are discarded
        histogram      - explicit bucket histogram
        last_value     - last recorded value
        sum            - sum of recorded values
        default        - SDK default for the instrument type
        exp_histogram  - base-2 exponential histogram

      An aggregation type can be specified using the 'aggr' keyword.  When
      specified, a metrics view is registered with the given aggregation
      strategy.  If omitted, the SDK default is used.

      For histogram instruments (hist_int), optional bucket boundaries can be
      specified using the 'bounds' keyword followed by a double-quoted string
      of space-separated numbers; values are sorted internally and duplicate
      boundaries are rejected.  A 'hist_int' instrument whose aggregation type
      is left unset defaults to histogram aggregation, whether or not bounds are
      given.  On any other instrument type a 'bounds' clause is a configuration
      error.

      Observable (asynchronous) and double-precision types are not supported.
      Observable instrument callbacks are invoked by the OTel SDK from an
      external background thread; HAProxy sample fetches rely on internal
      per-thread-group state and return incorrect results from a non-HAProxy
      thread.  Double-precision types are not supported because HAProxy sample
      fetches do not return double values.

      A create form registers the instrument and supplies the value to record;
      it emits no measurement on its own.  A data point is produced only when an
      update form for that instrument runs, recording the create form's value.
      An optional trailing 'if'/'unless' ACL condition takes part as described
      above: on a create form it decides whether that line supplies the value,
      so a lone conditional create still controls every recording, and on an
      update form it controls only that one measurement.  Registration follows
      that condition too, and produces no data point.

      The update form itself supplies no value, only attributes and an optional
      condition; to record a different quantity, define a separate instrument.
      To feed a computed or externally-set measurement into one instrument, set
      the create form's 'value' to a 'var(...)' fetch and assign that variable
      with 'set-var'; the instrument then records whatever the variable holds.

      For example, a request size read from a header is stored in a variable
      with 'set-var', then recorded through it:

        otel-scope request_size
            set-var txn.otel_size req.hdr(content-length)
            instrument hist_int req_bytes value var(txn.otel_size) unit "By"
            instrument update req_bytes
            otel-event on-frontend-http-request

      Observable (pull) instruments are unsupported, but the create/update split
      covers the same need when the update form runs periodically.  Bind a scope
      to the 'on-idle-timeout' event, whose timer fires on a HAProxy thread at
      the set interval, and run the gauge's update form there.  Each tick then
      records a data point from a sample fetch, evaluated on-thread, which an
      observable callback cannot do.  The example below records the frontend
      connection count every ten seconds:

        otel-scope active_connections
            idle-timeout 10s
            instrument gauge_int frontend_connections value fe_conn(otel-fe)
            instrument update frontend_connections
            otel-event on-idle-timeout

      List the scope under the instrumentation's 'scopes' directive to activate
      it.  The cadence comes from the idle streams, so at least one long-lived
      idle stream (a keep-alive or long-poll connection) must be present for the
      gauge to keep updating; under last-value aggregation, concurrent idle
      streams overwrite it with the most recent reading.

      Examples:
        instrument cnt_int  "name_cnt_int" desc "Integer Counter" value int(1),add(2) unit "unit"
        instrument hist_int "name_hist" aggr exp_histogram desc "Latency" value lat_ns_tot unit "ns"
        instrument hist_int "name_hist2" desc "Latency" value lat_ns_tot unit "ns" bounds "100 1000 10000"
        instrument cnt_int "name_cnt_local" value int(1) if { src 127.0.0.1 }
        instrument update "name_cnt_int" attr "attr_1_key" str("attr_1_value") if { src 127.0.0.1 }


  log-record <severity> [id <integer> event <name>] [time [<unit>] <sample>] [span <ref>] [attr <key> <sample>] ... <sample> ... [{ if | unless } <condition>]
      Emit an OpenTelemetry log record.  The first argument is a required
      severity level.  Optional keywords follow in any order, each of them once
      except 'attr':

        id <integer>           - numeric event identifier
        event <name>           - event name string
        time [<unit>] <sample> - override the log event timestamp at runtime
        span <ref>             - associate the log record with an open span
        attr <key> <sample>    - add an attribute evaluated at runtime (repeatable)

      The 'id' and 'event' keywords must be specified together or both omitted;
      any other combination is a configuration error.  The identifier must be
      at least 1; a zero is reserved as the internal no-id marker and rejected.

      The 'time' keyword takes an optional unit and a single HAProxy sample
      expression.  The supported units are 's' (seconds, the default), 'ms'
      (milliseconds), 'us' (microseconds) and 'ns' (nanoseconds); the value
      is interpreted as a count since the Unix epoch in the given unit.  The
      sample is evaluated at runtime and must yield an integer (or a string
      convertible to one).  The value sets the event timestamp; if evaluation
      or numeric conversion fails, it defaults to the wall-clock, which is
      always recorded as the observed timestamp.  Typical sources are HAProxy's
      'date' fetch family (e.g. date, date(0,ms), date(0,us)) or a numeric value
      extracted from a header or variable.

      The 'attr' keyword takes an attribute name and a single HAProxy sample
      expression.  The expression is evaluated at runtime, following the same
      rules as span attributes: a bare sample fetch (e.g. src) or a log-format
      string (e.g. "%[src]:%[src_port]").

      The remaining arguments at the end are sample fetch expressions that form
      the log record body.  A single sample preserves its native type; multiple
      samples are concatenated as a string.  The body may also be a HAProxy
      log-format string wrapped in '%[ ... ]'; the outer wrapper is stripped
      before parsing, and the inner string may then contain log-format aliases
      such as %ci, %t or %ft.

      Supported severity levels follow the OpenTelemetry specification:
        trace, trace2, trace3, trace4
        debug, debug2, debug3, debug4
        info,  info2,  info3,  info4
        warn,  warn2,  warn3,  warn4
        error, error2, error3, error4
        fatal, fatal2, fatal3, fatal4

      The log record is only emitted if the logger is enabled for the configured
      severity (controlled by the 'min_severity' option in the YAML logs signal
      configuration).  If a 'span' reference is given but the named span is not
      found at runtime, the log record is emitted without span correlation.

      An optional trailing 'if'/'unless' ACL condition covers the whole record;
      when it does not pass at runtime, the record is skipped entirely, with no
      body or attributes evaluated.

      Examples:
        log-record info str("heartbeat")
        log-record info id 1001 event "http-request" span "Frontend HTTP request" attr "http.method" method method url
        log-record trace id 1000 event "session-start" span "Client session" attr "src_ip" src attr "src_port" src_port src str(":") src_port
        log-record warn id 1018 event "server-unavailable" str("503 Service Unavailable")
        log-record info id 1001 event "session-stop" str("stream stopped")
        log-record info id 1002 event "server-session-end" "%[%ci:%cp [%t] %ft %b/%s %Tw/%Tc/%Tt %B %ts]"
        log-record info time us date(0,us) str("microsecond-accurate event")
        log-record info time ns hdr(x-event-time-ns) str("client-supplied timestamp")
        log-record info str("local heartbeat") if { src 127.0.0.1 }


  set-var <var-name> <sample> ... [{ if | unless } <condition>]
      Set a HAProxy variable to the string value of a sample expression or a
      log-format, evaluated when the scope's event fires.  The value grammar
      matches the 'attribute' keyword: bare sample fetches are concatenated and
      a '%[ ... ]' wrapper marks a HAProxy log-format string.  The result is
      always stored as a string.  The variable name includes its scope (proc,
      sess, txn, req or res) and is registered at parse time.  Assignments run
      before the scope's spans, so span attributes, instruments and log records
      can read the new value through 'var(...)'.

      An optional trailing 'if'/'unless' ACL condition covers the assignment.
      The keyword may be repeated, one line per condition: the lines of one
      variable are tried in the configuration order, the first whose condition
      holds sets that variable, and a line without a condition, placed last,
      acts as the variable's default.

      Examples:
        set-var txn.otel_method method
        set-var txn.otel_path str("path=") path
        set-var sess.otel_tid var(sess.otel.uuid)
        set-var txn.otel_internal str("1") if { src 127.0.0.1 }


  set-var-ctx <var-name> <ref> <field> [{ if | unless } <condition>]
      Set a HAProxy variable from a field of a referenced span or span context,
      evaluated when the scope's event fires.  <ref> names a span or a context
      that some otel-scope defines, this one or another; it is resolved against
      the active spans first and then the extracted contexts, and an unresolved
      reference leaves the variable unchanged.  Because these assignments run
      after the scope's spans, a span created in the same scope may be
      referenced.

      An optional trailing 'if'/'unless' ACL condition covers the assignment.
      The keyword may be repeated, one line per condition: the lines of one
      variable are tried in the configuration order, the first whose condition
      holds sets that variable, and a line without a condition, placed last,
      acts as the variable's default.

      Supported fields:
        trace-id          - trace id, lowercase hex
        span-id           - span id, lowercase hex
        trace-flags       - W3C trace-flags byte, lowercase hex
        traceparent       - full W3C "00-<trace>-<span>-<flags>" string
        tracestate        - full tracestate header (context references only)
        tracestate(<key>) - a single tracestate entry (context references only)
        baggage           - the whole baggage carrier (all entries)
        baggage(<key>)    - a single baggage entry value
        sampled           - "1" if the sampled flag is set, else "0"
        valid             - "1" if the context is valid, else "0"
        remote            - "1" if the context is remote, else "0"

      For 'baggage(<key>)', a span reference reads the value set on that span,
      while a context reference reads the named entry from the inbound carrier.
      Keyless 'baggage' returns the whole carrier instead: the inbound carrier
      for a context reference, or the serialized outbound baggage for a span
      reference.  The 'tracestate' fields are available only when <ref> names a
      context.

      A key written between the parentheses follows the rule of its W3C carrier,
      and the two differ.  A baggage key is an HTTP token: letters, digits and
      the punctuation "!#$%&'*+-.^_`|~", at most 4096 characters of them.  A
      tracestate key holds at most 256 lower-case letters, digits, '_', '-',
      '*' and '/' and begins with a letter, or names a tenant id of at most 241
      characters and a system id of at most 14 around a single '@', the tenant
      id opening with a letter or a digit and the system id with a letter.  No
      carrier holds another key, so one written here is refused.

      The <ref> is a span or context name, quoted as in 'span', 'inject' and
      'extract'.  The example below first declares them, then references them.

      Examples:
        extract "inbound" use-headers
        span "HAProxy session" root
        set-var-ctx txn.in_traceid  "inbound" trace-id
        set-var-ctx txn.traceparent "HAProxy session" traceparent
        set-var-ctx txn.user        "inbound" baggage(userId)
        set-var-ctx txn.local_tid   "HAProxy session" trace-id if { src 127.0.0.1 }


  unset-var <var-name> ... [{ if | unless } <condition>]
      Remove one or more HAProxy variables when the scope's event fires.  A
      variable that is not currently set is silently ignored.  Removal runs
      after the scope's spans, instruments and log records, so all still read
      the variables; only 'finish' and 'otel-stop' run afterwards.

      An optional trailing 'if'/'unless' ACL condition covers the whole
      directive; when it does not pass, none of the listed variables are
      removed.  The keyword may be repeated, one line per condition: the lines
      of one variable are tried in the configuration order, the first whose
      condition holds removes it, and a line without a condition, placed last,
      acts as the variable's default.  A variable named twice on one line is a
      configuration error.

      Examples:
        unset-var txn.otel_scratch txn.otel_tmp
        unset-var txn.otel_scratch if { src 127.0.0.1 }


  acl <aclname> <criterion> [flags] [operator] <value> ...
      Declare an ACL local to this scope.  Each <aclname> may be declared only
      once within the section; a duplicate name is a configuration error.  The
      name may be used only in the conditions of this scope, where it hides an
      otel-instrumentation ACL of the same name.  Each name of a condition is
      looked up on its own, in the scope first, then in the otel-instrumentation
      and then in the HAProxy configuration, so one condition may mix them.  The
      ACL has to exist by the time the line is read: an otel-instrumentation
      section below this scope, or a HAProxy 'acl' line below the 'filter' line,
      comes too late and the name is refused.  Everything after the criterion
      is pattern text, so an 'if'/'unless' there is taken as a pattern value and
      not as a condition.

      Example:
        acl acl-test-src-ip src 127.0.0.1


  otel-event <name> [{ if | unless } <condition>]
      Bind this scope to a filter event, optionally with an ACL-based condition.
      A scope binds to a single event, so 'otel-event' may appear at most once;
      a second is a configuration error.  A scope without the keyword runs only
      where an 'otel-group' action names a group that holds it; when no group
      does, it never runs and a warning says so at load.  On this and every
      other keyword that takes a condition, a bare 'if' or 'unless' with no
      ACL terms after it is rejected.  They are likewise rejected as a span or
      context reference and as the name of a span, an otel-scope, an otel-group
      or an extract context.

      The event also fixes where the sample fetches and ACLs of the scope are
      evaluated.  A fetch that reads data HAProxy does not hold at that point, a
      response header at a request event for instance, yields nothing at run
      time, so the check reports it as a warning, in a condition as well as in a
      value; a scope that an 'otel-group' action runs is checked against the
      rule set holding the action the same way.

      The filter attaches to both HTTP-mode and TCP-mode proxies.  A TCP proxy
      carries no HTTP message and runs no HTTP analysis, so the events tagged
      '(HTTP only)' below never fire on one: a scope bound to such an event on a
      non-HTTP proxy is inert and reported as a warning at config load.  Every
      unmarked event can fire on both proxy modes, with one difference: on an
      HTTP proxy the filter enables the analysers of its events itself, while
      on a raw TCP stream it cannot, so there an analyser event fires only when
      the proxy's own rules schedule the analyser:

        tcp-request content rules   on-frontend-tcp-request,
                                    on-backend-tcp-request
        use-server rules            on-process-server-rules-request
        persist rdp-cookie          on-tcp-rdp-cookie-request
        stick rules                 on-process-sticking-rules-request,
                                    on-process-store-rules-response
        tcp-response content rules  on-tcp-response

      The lifecycle and session events fire on every connection.

      Where the filter line stands matters too.  A filter of a backend section
      joins the stream at backend selection, after the frontend-phase analysers
      have run and without the stream-start callback, and HAProxy releases it as
      soon as both channels have ended their analysis, before the stream-stop
      callback, so these events never fire for it, which a warning reports at
      config load:

        on-stream-start
        on-frontend-tcp-request
        on-http-wait-request
        on-frontend-http-request
        on-switching-rules-request
        on-stream-stop

      The on-http-body-request event does fire for such a filter, but only when
      the backend itself asks for the body, with 'option http-buffer-request' or
      with 'balance url_param', and the frontend did not; a scope bound to it in
      a backend that asks for neither draws the same warning.

      For such a filter on-idle-timeout runs from the moment it is attached.  On
      a listen proxy, HAProxy skips the backend-phase request analysers when the
      frontend is the backend, so on that proxy the on-backend-tcp-request and
      on-backend-http-request events fire only for a stream switched to another
      backend or routed in from another frontend; a warning reports such a scope
      too.

      Supported events (stream lifecycle):
        on-stream-start
        on-stream-stop
        on-idle-timeout
        on-backend-set

      Supported events (request channel):
        on-client-session-start
        on-frontend-tcp-request
        on-http-wait-request               (HTTP only)
        on-http-body-request               (HTTP only)
        on-frontend-http-request           (HTTP only)
        on-switching-rules-request
        on-backend-tcp-request
        on-backend-http-request            (HTTP only)
        on-http-tarpit-request             (HTTP only)
        on-process-server-rules-request
        on-http-process-request            (HTTP only)
        on-tcp-rdp-cookie-request
        on-process-sticking-rules-request
        on-http-headers-request            (HTTP only)
        on-http-end-request                (HTTP only)
        on-client-session-end
        on-server-unavailable

      Supported events (response channel):
        on-server-session-start
        on-tcp-response
        on-http-wait-response              (HTTP only)
        on-process-store-rules-response
        on-http-response                   (HTTP only)
        on-http-headers-response           (HTTP only)
        on-http-end-response               (HTTP only)
        on-http-reply                      (HTTP only)
        on-server-session-end

      The on-stream-start event fires from the stream_start filter callback,
      before any channel processing begins.  The on-stream-stop event fires from
      the stream_stop callback, after all channel processing ends.  No channel
      is available at that point, so context injection/extraction via HTTP
      headers cannot be used in scopes bound to these events.

      The on-idle-timeout event fires periodically when the stream has no data
      transfer activity.  It requires the 'idle-timeout' keyword to set the
      interval.  Scopes bound to this event can create heartbeat spans, record
      idle-time metrics, and emit idle-time log records.  The idle timer is
      driven by the stream task itself, so it keeps firing while an analyser
      holds the stream, such as during a tarpit timeout.  See the 'instrument'
      keyword for driving a periodic gauge from it.

      The on-backend-set event fires when a backend is assigned to the stream,
      including when the frontend and the backend are the same proxy.

      The on-http-headers-request and on-http-headers-response events fire after
      all HTTP headers have been parsed and analyzed.

      The on-http-end-request and on-http-end-response events fire when all HTTP
      data has been processed and forwarded.

      The response-channel HTTP events fire only for a response received from a
      server.  A reply synthesised by HAProxy itself -- such as the 503 returned
      when no connection to a server could be established, or the 504 sent on a
      server timeout -- does not run the scopes bound to these events.  To trace
      such replies, attach the scope through an 'http-after-response otel-group'
      action instead, as described for on-http-reply below.

      The on-http-reply event is meant to fire when HAProxy returns an internal
      reply (error page, deny response, redirect), but it never fires on any
      current HAProxy.  The http_reply filter callback has had no caller since
      HAProxy 2.2-dev8 (2020), when its HTTP error handling was reworked around
      the 'struct http_reply' mechanism; the flt_http_reply() dispatcher still
      exists but nothing ever calls it.  To trace such replies, attach the scope
      through an 'http-after-response otel-group' action instead, which runs on
      every response, including the ones that HAProxy synthesises.

      Examples:
        otel-event on-stream-start if acl-test-src-ip
        otel-event on-stream-stop
        otel-event on-client-session-start
        otel-event on-client-session-start if acl-test-src-ip
        otel-event on-http-response if !acl-http-status-ok
        otel-event on-idle-timeout


  otel-stop [{ if | unless } <condition>]
      Stop the OpenTelemetry filter for the current connection.  Every span and
      span context still open on the stream is finished, and the filter is then
      disabled for the rest of that connection, so no further scopes run for it,
      whether triggered by a later event or by an 'otel-group' action.  It is
      the per-connection counterpart to the global 'flt-otel disable' CLI
      command.

      The stop is evaluated after the rest of the scope has run, so a stopping
      scope still emits the spans, log records and metrics it defines first.

      Without a condition the stop always fires.  An optional 'if' or 'unless'
      condition (same syntax as 'otel-event') makes the stop conditional.  The
      keyword may be repeated, one line per condition, and the stop fires when
      the condition of any line holds.  The bare form without a condition sets
      the stop definitively, so it must be the last line: any 'otel-stop' that
      follows it is a configuration error, since it could not change anything.

      Examples:
        otel-stop
        otel-stop if { path_beg /health }


  idle-timeout <time>
      Set the idle timeout interval for a scope bound to the 'on-idle-timeout'
      event.  The timer fires periodically at the given interval when the stream
      has no data transfer activity.  This keyword is mandatory for scopes using
      the 'on-idle-timeout' event and cannot be used with any other event, nor
      with a scope that carries no 'otel-event' at all.  Only one 'idle-timeout'
      may be defined per scope; a second is a configuration error.

      The <time> argument accepts the standard HAProxy time format: a number
      followed by a unit suffix (us, ms, s, m, h, d).  A value of zero is not
      permitted.

      Example:
        scopes on_idle_timeout
        ..
        otel-scope on_idle_timeout
            idle-timeout 5s
            span "heartbeat" root
                attribute "idle.elapsed" str("idle-check")
            instrument cnt_int "idle.count" value int(1)
            log-record info str("heartbeat")
            otel-event on-idle-timeout


3.4. "otel-group" section
---------------------------

An "otel-group" section defines a named collection of scopes that are triggered
from HAProxy TCP/HTTP rules rather than from filter events.  The group name
is unique within the top-level OTel scope, so a second group of that name is a
configuration error, while another top-level scope may reuse it.  The section
must hold at least one 'scopes' line; a group with none is an error.  A 'groups'
line of the otel-instrumentation section must name the group; one those lines
leave out is an error as well.

Syntax:

  otel-group <name>

Keywords:

  scopes <name> ...
      List the "otel-scope" sections that belong to this group.  Multiple names
      can be given on one line and the keyword may be repeated.  Every name must
      be a defined section and is unique in the group; a repeat is a
      configuration error.  Scopes that are used only in groups do not need to
      define an 'otel-event'.  A trailing 'if'/'unless' condition is not
      accepted.

Example (from test/sa/otel.cfg):

  otel-group http_response_group
      scopes http_response_1
      scopes http_response_2

  otel-scope http_response_1
      span "HTTP response"
          event "event_content" "hdr.content" res.hdr("content-type") str("; length: ") res.hdr("content-length") str(" bytes")

  otel-scope http_response_2
      span "HTTP response"
          event "event_date" time ms date(0,ms) "hdr.date" res.hdr("date") str(" / ") res.hdr("last-modified")


4. YAML configuration file
----------------------------

The YAML configuration file defines the OpenTelemetry SDK pipeline.  It is
referenced by the 'config' keyword in the "otel-instrumentation" section.
It contains the following top-level sections: exporters, samplers, processors,
readers, providers and signals.


4.1. Exporters
---------------

Each exporter has a user-chosen name and a 'type' that determines which
additional options are available.  Options marked with (*) are required.

The 'cpu_id' option, accepted by the OTLP exporters, the batch processors and
the readers, pins that component's background thread to the given CPU.  The
default of -1 leaves the thread unpinned.

Supported types:

  otlp_grpc - Export via OTLP over gRPC.
    type (*)                          "otlp_grpc"
    thread_name                       exporter thread name (string)
    cpu_id                            exporter thread CPU affinity (integer)
    endpoint                          OTLP/gRPC endpoint URL (string)
    use_ssl_credentials               enable SSL channel credentials (boolean)
    ssl_credentials_cacert_path       CA certificate file path (string)
    ssl_credentials_cacert_as_string  CA certificate as inline string (string)
    ssl_client_key_path               client private key file path (string)
    ssl_client_key_string             client private key as inline string (string)
    ssl_client_cert_path              client certificate file path (string)
    ssl_client_cert_string            client certificate as inline string (string)
    timeout                           export timeout in seconds (integer)
    user_agent                        User-Agent header value (string)
    max_threads                       maximum exporter threads (integer)
    compression                       compression algorithm name (string)
    max_concurrent_requests           concurrent request limit (integer)


  otlp_http - Export via OTLP over HTTP (JSON or Protobuf).
    type (*)                        "otlp_http"
    thread_name                     exporter thread name (string)
    cpu_id                          exporter thread CPU affinity (integer)
    endpoint                        OTLP/HTTP endpoint URL (string)
    content_type                    payload format: "json" or "protobuf"
    json_bytes_mapping              binary encoding: "hexid", "utf8" or "base64"
    use_json_name                   use protobuf JSON field names (boolean)
    debug                           enable debug output (boolean)
    timeout                         export timeout in seconds (integer)
    http_headers                    custom HTTP headers (list of key: value)
    max_concurrent_requests         concurrent request limit (integer)
    max_requests_per_connection     request limit per connection (integer)
    background_thread_wait_for      idle timeout for the HTTP background thread
                                    in milliseconds; 0 means the thread never
                                    exits on its own (integer, default: 0).  If
                                    this option is set, 'insecure-fork-wanted'
                                    must be used in the HAProxy configuration,
                                    otherwise HAProxy may crash while exporting
                                    OTel data
    ssl_insecure_skip_verify        skip TLS certificate verification (boolean)
    ssl_ca_cert_path                CA certificate file path (string)
    ssl_ca_cert_string              CA certificate as inline string (string)
    ssl_client_key_path             client private key file path (string)
    ssl_client_key_string           client private key as inline string (string)
    ssl_client_cert_path            client certificate file path (string)
    ssl_client_cert_string          client certificate as inline string (string)
    ssl_min_tls                     minimum TLS version (string)
    ssl_max_tls                     maximum TLS version (string)
    ssl_cipher                      TLS cipher list (string)
    ssl_cipher_suite                TLS 1.3 cipher suite list (string)
    compression                     compression algorithm name (string)


  otlp_file - Export to local files in OTLP format.
    type (*)                        "otlp_file"
    thread_name                     exporter thread name (string)
    cpu_id                          exporter thread CPU affinity (integer)
    file_pattern                    output filename pattern (string)
    alias_pattern                   symlink pattern for latest file (string)
    flush_interval                  flush interval in microseconds (integer)
    flush_count                     spans per flush (integer)
    file_size                       maximum file size in bytes (integer)
    rotate_size                     number of rotated files to keep (integer)


  ostream - Write to a file (text output, useful for debugging).
    type (*)                        "ostream"
    filename                        output file path (string)


  memory - In-memory buffer (useful for testing).
    type (*)                        "memory"
    buffer_size                     maximum buffered items (integer)


  zipkin - Export to Zipkin-compatible backends.
    type (*)                        "zipkin"
    endpoint                        Zipkin collector URL (string)
    format                          payload format: "json" or "protobuf"
    service_name                    service name reported to Zipkin (string)
    ipv4                            service IPv4 address (string)
    ipv6                            service IPv6 address (string)


  elasticsearch - Export to Elasticsearch.
    type (*)                        "elasticsearch"
    host                            Elasticsearch hostname (string)
    port                            Elasticsearch port (integer)
    index                           Elasticsearch index name (string)
    response_timeout                response timeout in seconds (integer)
    debug                           enable debug output (boolean)
    http_headers                    custom HTTP headers (list of key: value)


4.2. Samplers
--------------

Samplers control which traces are recorded.  Each sampler has a user-chosen
name and a 'type' that determines its behavior.

Supported types:

  always_on - Sample every trace.
    type (*)                        "always_on"


  always_off - Sample no traces.
    type (*)                        "always_off"


  trace_id_ratio_based - Sample a fraction of traces.
    type (*)                        "trace_id_ratio_based"
    ratio (*)                       sampling ratio, 0.0 to 1.0 (float)


  parent_based - Inherit sampling decision from parent span.
    type (*)                        "parent_based"
    delegate                        root-span sampler: "always_on" (default),
                                    "always_off" or "trace_id_ratio_based"
                                    (the latter reads 'ratio' as above)
    remote_sampled                  sampler for a sampled remote parent:
                                    "always_on" (default) or "always_off"
    remote_not_sampled              sampler for a not-sampled remote parent:
                                    "always_on" or "always_off" (default)
    local_sampled                   sampler for a sampled local parent:
                                    "always_on" (default) or "always_off"
    local_not_sampled               sampler for a not-sampled local parent:
                                    "always_on" or "always_off" (default)


A span the sampler leaves out is not exported, and the SDK drops the attributes,
the events, the links, the status and the exceptions that are attached to it.
The filter therefore evaluates none of those samples for such a span, which is
what makes a trace that is not sampled inexpensive.  The decision is taken for
each span on its own, so a sampler that records the child of a span it left out
still receives the samples of that child.  What the downstream services need is
kept in every case: the span is created, the 'traceparent' header carries the
trace with the sampled flag cleared, and the baggage is set as configured.


4.3. Processors
----------------

Processors define how telemetry data is handled before export.  Each
processor has a user-chosen name and a 'type' that determines its behavior.

Supported types:

  batch - Batch spans before exporting.
    type (*)                        "batch"
    thread_name                     processor thread name (string)
    cpu_id                          processor thread CPU affinity (integer)
    max_queue_size                  maximum queued spans (integer)
    schedule_delay                  export interval in milliseconds (integer)
    export_timeout                  export timeout in milliseconds (integer)
    max_export_batch_size           maximum spans per export call (integer)

    When the queue reaches half capacity, a preemptive notification triggers
    an early export.  The 'max_queue_size' value must not be lower than the
    'max_export_batch_size' value, and 'export_timeout' is accepted but not
    yet used by the OpenTelemetry C++ SDK batch processor.

  single - Export each span individually (no batching).
    type (*)                        "single"


4.4. Readers
-------------

Readers define how metrics are collected and exported.  Each reader has a
user-chosen name.

    thread_name                     reader thread name (string)
    cpu_id                          reader thread CPU affinity (integer)
    export_interval                 collection interval in milliseconds (integer)
    export_timeout                  export timeout in milliseconds (integer)

The 'export_timeout' value must not exceed the 'export_interval' value.


4.5. Providers
---------------

Providers define resource attributes attached to all telemetry data.  Each
provider has a user-chosen name.

    resources                       key-value resource attributes (list)

Standard resource attribute keys include service.name, service.version,
service.instance.id and service.namespace.


4.6. Signals
-------------

Signals bind exporters, samplers, processors, readers and providers together
for each telemetry type.  The supported signal names are "traces", "metrics"
and "logs".

Under each signal name, the bindings are grouped into one or more named signals
contexts; the 'config' keyword of the "otel-instrumentation" section selects
the context that serves the filter, and the name 'default' is used when the
keyword names no context:

    traces:
      default:
        scope_name: ...

The flat layout without the context level, written for the wrapper 2.x releases,
is still accepted.

    scope_name (*)                  instrumentation scope name (string)
    exporters                       exporter name reference (string or list)
    samplers                        sampler name reference (string, traces only)
    processors                      processor name reference (string or list,
                                    traces/logs)
    readers                         reader name reference (string, metrics only)
    providers                       provider name reference (string)
    min_severity                    minimum log severity level (string, logs only)

For the traces and logs signals, 'processors' may be a YAML sequence to build
several pipelines: each processor is paired with the exporter at the same
position in the 'exporters' sequence, and when the exporter list is shorter
its last entry is reused for the remaining processors.

The "min_severity" option controls which log records are emitted.  Only log
records whose severity is equal to or higher than the configured minimum are
passed to the exporter.  The value is a severity name as listed under the
"log-record" keyword (e.g. "trace", "debug", "info", "warn", "error", "fatal").
If omitted, the logger accepts all severity levels.


5. HAProxy rule integration
----------------------------

Groups defined in the OTel configuration file can be triggered from HAProxy
TCP/HTTP rules using the 'otel-group' action keyword:

  http-request         otel-group <filter-id> <group> [condition]
  http-response        otel-group <filter-id> <group> [condition]
  http-after-response  otel-group <filter-id> <group> [condition]
  tcp-request content  otel-group <filter-id> <group> [condition]
  tcp-response content otel-group <filter-id> <group> [condition]

This allows running specific groups of scopes based on ACL conditions defined
in the HAProxy configuration.

Both names are resolved when the configuration is checked: the filter id has
to name an OTel filter of that proxy and the group has to be named on a 'groups'
line of the otel-instrumentation section that filter reads, or HAProxy refuses
to start.  A group section those lines leave out is an error on its own.  The
scopes of the group are checked against that rule set as well: a sample fetch or
a condition that cannot be evaluated there is reported as a warning at load.

Example (from test/sa/haproxy.cfg):

  acl acl-http-status-ok status 100:399

  filter opentelemetry id otel-test-sa config sa/otel.cfg

  # Run response scopes for successful responses
  http-response otel-group otel-test-sa http_response_group if acl-http-status-ok

  # Run after-response scopes for error responses
  http-after-response otel-group otel-test-sa http_after_response_group if !acl-http-status-ok

The filter also registers a sample fetch, usable in any HAProxy ACL or rule,
that reports whether a usable span context was extracted on the current stream
by the 'extract' keyword:

  otel.context(<name>) : boolean

It returns true when the context named <name> was extracted and is valid.  When
no such context was extracted the fetch produces no sample, so a '-m found'
match reads as false; this tells "a context arrived" apart from "none arrived"
without first copying a context field into a variable with 'set-var-ctx'.

Example:

  acl otel-ctx-valid otel.context(otel-ctx)
  acl otel-ctx-seen  otel.context(otel-ctx) -m found

  # honour the upstream trace decision rather than re-deriving it
  http-request set-header X-Trace-Continued 1 if otel-ctx-valid

The filter also exposes the raw payload byte counts accumulated on the stream:

  otel.bytes_in  : integer
  otel.bytes_out : integer

'otel.bytes_in' counts the payload forwarded on the request channel (client to
HAProxy) and 'otel.bytes_out' the payload on the response channel (HAProxy to
client).  Both grow as data is forwarded, so they are best read from a close
scope -- on-client-session-end or on-server-session-end -- where they hold the
final connection totals.  They are populated in TCP mode; on an HTTP proxy they
read 0 because an HTX buffer carries protocol framing rather than raw payload.

Example (record the transferred volume on a TCP connection span):

  otel-scope client_session_end
      span "TCP session"
          attribute "tcp.bytes_in"  otel.bytes_in
          attribute "tcp.bytes_out" otel.bytes_out
      finish *
      otel-event on-client-session-end


6. Complete examples
---------------------

The test directory contains several complete example configurations.  Each
subdirectory contains an OTel configuration file (otel.cfg), a YAML file
(otel.yml) and a HAProxy configuration file (haproxy.cfg).


6.1. Standalone example (sa)
------------------------------

The most comprehensive single-instance example: most of the events are used
(the test/README-sa file lists the exact coverage), with spans, attributes,
events, links, baggage, status, metrics and groups demonstrated.

--- test/sa/otel.cfg (excerpt) -----------------------------------------

[otel-test-sa]
    otel-instrumentation otel-test-instrumentation
        config sa/otel.yml
        option dontlog-normal
        option hard-errors
        no option disabled
        rate-limit 100.0

        groups http_response_group
        groups http_after_response_group

        scopes skip_health_path
        scopes on_stream_start
        scopes on_stream_stop
        scopes on_idle_timeout
        scopes client_session_start
        scopes frontend_tcp_request
        ...
        scopes server_session_end

    otel-group http_response_group
        scopes http_response_1
        scopes http_response_2

    otel-scope http_response_1
        span "HTTP response"
            event "event_content" "hdr.content" res.hdr("content-type") str("; length: ") res.hdr("content-length") str(" bytes")

    otel-scope on_stream_start
        instrument udcnt_int "haproxy.sessions.active" desc "Active sessions" value int(1) unit "{session}"
        span "HAProxy session" root
            baggage "haproxy_id" var(sess.otel.uuid)
            event "event_ip" "src" src str(":") src_port
        acl acl-test-src-ip src 127.0.0.1
        otel-event on-stream-start if acl-test-src-ip

    otel-scope on_stream_stop
        finish *
        otel-event on-stream-stop

    otel-scope client_session_start
        span "Client session" parent "HAProxy session"
        otel-event on-client-session-start

    otel-scope frontend_http_request
        span "Frontend HTTP request" parent "HTTP body request" link "HAProxy session" kind server
            attribute "http.method" method
            attribute "http.url" url
            attribute "http.version" str("HTTP/") req.ver
        finish "HTTP body request"
        otel-event on-frontend-http-request

    otel-scope server_session_start
        span "Server session" parent "HAProxy session" kind client
        link "HAProxy session" "Client session"
        finish "Process sticking rules request"
        otel-event on-server-session-start

    otel-scope server_session_end
        finish "*res*"
        otel-event on-server-session-end

---------------------------------------------------------------------


6.2. Frontend / backend example (fe/be)
-----------------------------------------

Demonstrates distributed tracing across two cascaded HAProxy instances using
inject/extract to propagate the span context via HTTP headers.

The frontend HAProxy (test/fe) creates the root trace and injects context:

--- test/fe/otel.cfg (excerpt) -----------------------------------------

    otel-scope backend_http_request
        span "Backend HTTP request" parent "Backend TCP request"
        finish "Backend TCP request"
        span "HAProxy session"
            inject "otel-ctx" use-headers
        otel-event on-backend-http-request

---------------------------------------------------------------------

The backend HAProxy (test/be) extracts the context and continues the trace:

--- test/be/otel.cfg (excerpt) -----------------------------------------

    otel-scope frontend_http_request
        extract "otel-ctx" use-headers
        span "HAProxy session" parent "otel-ctx" root
            baggage "haproxy_id" var(sess.otel.uuid)
        span "Client session" parent "HAProxy session"
        span "Frontend HTTP request" parent "Client session"
            attribute "http.method" method
            attribute "http.url" url
            attribute "http.version" str("HTTP/") req.ver
        otel-event on-frontend-http-request

---------------------------------------------------------------------


6.3. Context propagation example (ctx)
----------------------------------------

Similar to 'sa', but spans are opened using extracted span contexts as parent
references instead of direct span names.  This demonstrates the inject/extract
mechanism using HAProxy variables.

--- test/ctx/otel.cfg (excerpt) ----------------------------------------

    otel-scope client_session_start_1
        span "HAProxy session" root
            inject "otel_ctx_1" use-headers use-vars
            baggage "haproxy_id" var(sess.otel.uuid)
        otel-event on-client-session-start

    otel-scope client_session_start_2
        extract "otel_ctx_1" use-vars
        span "Client session" parent "otel_ctx_1"
            inject "otel_ctx_2" use-headers use-vars
        otel-event on-client-session-start

    otel-scope frontend_tcp_request
        extract "otel_ctx_2" use-vars
        span "Frontend TCP request" parent "otel_ctx_2"
            inject "otel_ctx_3" use-headers use-vars
        otel-event on-frontend-tcp-request

    otel-scope http_wait_request
        extract "otel_ctx_3" use-vars
        span "HTTP wait request" parent "otel_ctx_3"
        finish "Frontend TCP request" "otel_ctx_3"
        otel-event on-http-wait-request

---------------------------------------------------------------------


6.4. TCP-mode example (tcp)
-----------------------------

Runs the filter on a 'mode tcp' proxy.  With no HTTP message to inspect, it
traces the raw stream: a single "TCP session" span covers the whole connection,
each TCP-phase event records a timestamped span event onto it, and the forwarded
payload is counted through the 'otel.bytes_in' and 'otel.bytes_out' fetches.
The configuration exercises every event that fires on a TCP-mode proxy; the
HTTP-only events are left out, since they never fire there.

--- test/tcp/otel.cfg (excerpt) ----------------------------------------

    otel-scope stream_start
        span "TCP session" root
            attribute "client.addr"   src str(":") src_port
            attribute "frontend.addr" dst str(":") dst_port
            event "stream-start" "fired" int(1)
        otel-event on-stream-start

    otel-scope client_session_end
        span "TCP session"
            attribute "tcp.bytes_in"  otel.bytes_in
            attribute "tcp.bytes_out" otel.bytes_out
            event "client-session-end" "fired" int(1)
        otel-event on-client-session-end

    otel-scope stream_stop
        span "TCP session"
            event "stream-stop" "fired" int(1)
        finish *
        otel-event on-stream-stop

---------------------------------------------------------------------


6.5. Comparison example (cmp)
-------------------------------

A configuration made for comparison purposes with other tracing implementations.
It uses a simplified span hierarchy without context propagation.

--- test/cmp/otel.cfg (excerpt) ----------------------------------------

    otel-scope client_session_start
        span "HAProxy session" root
            baggage "haproxy_id" var(sess.otel.uuid)
        span "Client session" parent "HAProxy session"
        otel-event on-client-session-start

    otel-scope http_response-error
        span "HTTP response"
            status "error" str("!acl-http-status-ok")
        otel-event on-http-response if !acl-http-status-ok

    otel-scope server_session_end
        finish "HTTP response" "Server session"
        otel-event on-http-response

    otel-scope client_session_end
        finish "*"
        otel-event on-http-response

---------------------------------------------------------------------


6.6. Up-down counter example (updown)
---------------------------------------

An up-down counter (udcnt_int) that rises on one event and falls on another:
the 'haproxy.sessions.active' instrument tracks how many client sessions are
currently open, +1 when a session starts and -1 when it ends.  The create form
supplies a single value expression, so the per-event sign is carried through a
session variable: 'set-var' stores +1 in the session_start scope and -1 in the
session_end scope, and the instrument reads the variable back when each 'update'
form records the measurement.  A second counter tallies the total number of
sessions started.  The metrics are written by an ostream exporter and read from
its output file after a clean shutdown.

--- test/updown/otel.cfg (excerpt) -------------------------------------

    otel-scope session_start
        instrument udcnt_int "haproxy.sessions.total" desc "Total client sessions" value int(1) unit "{session}"
        instrument update "haproxy.sessions.total"
        set-var sess.otel_updown_delta int(1)
        instrument udcnt_int "haproxy.sessions.active" desc "Active client sessions" value var(sess.otel_updown_delta) unit "{session}"
        instrument update "haproxy.sessions.active"
        otel-event on-client-session-start

    otel-scope session_end
        set-var sess.otel_updown_delta int(-1)
        instrument update "haproxy.sessions.active"
        otel-event on-client-session-end

---------------------------------------------------------------------


6.7. Error-logging example (err)
----------------------------------

A configuration that drives the filter's runtime error reporting on ordinary
traffic.  The response scope parents its span under 'orphan span', which only
the on-server-unavailable scope creates; that event does not fire while the
backend is alive, so the parent is absent from the runtime context and span
creation fails on every response.  In the default soft-errors mode the error is
swallowed at warning level and the request completes; switching the instance to
hard-errors mode logs at error level and disables the filter for the offending
stream.  The log lines are edge-triggered and rate-limited; the error counters
are tracked over the CLI with 'flt-otel status'.

--- test/err/otel.cfg (excerpt) ----------------------------------------

    otel-scope session_start
        span "HAProxy session" root
        otel-event on-client-session-start

    otel-scope server_unavailable
        span "orphan span" parent "HAProxy session"
        otel-event on-server-unavailable

    otel-scope http_response
        span "HTTP response" parent "orphan span"
            attribute "http.status_code" status
        otel-event on-http-response

---------------------------------------------------------------------


6.8. Empty / minimal example (empty)
--------------------------------------

The minimal valid OTel configuration.  The filter is initialized but no events
are triggered:

--- test/empty/otel.cfg -------------------------------------------------

  [otel-test-empty]
      otel-instrumentation otel-test-instrumentation
          config empty/otel.yml default

---------------------------------------------------------------------

This is useful for testing the OTel filter initialization behavior without any
actual telemetry processing.
