metadata: supported to access specific item of ListValue by the MetadataKey - #45948
metadata: supported to access specific item of ListValue by the MetadataKey#45948yusofg2 wants to merge 5 commits into
Conversation
|
Hi @yusofg2, welcome and thank you for your contribution. We will try to review your Pull Request as quickly as possible. In the meantime, please take a look at the contribution guidelines if you have not done so already. |
|
CC @envoyproxy/api-shepherds: Your approval is needed for changes made to |
wbpcode
left a comment
There was a problem hiding this comment.
Thanks for this contribution. I guess this two different parts to optimize:
- extraction: I inclined to keep the value that https://github.com/grpc-ecosystem/proto-field-extraction lib extracted. If we want to support simple value (at non-list format), we'd better to create PR at upstream.
- access: this part is what we can control in our code base. We could enhance the
message MetadataKeyto support to access list value. Then, we can let the rate limit action access the string value from a list (I can take a check, this should be pretty simple).
/wait
Extends MetadataKey PathSegment to support list element access via index.
This allows filters that consume metadata (e.g. ratelimit) to unwrap ListValue
entries written by grpc_field_extraction without requiring changes to the
extraction filter itself.
Proto changes:
- Add uint32 index field to PathSegment oneof
- Update documentation with list access examples
C++ implementation:
- Add PathSegment struct with Type enum (Key/Index)
- Implement new structValue() overload for PathSegment paths
- Add bounds checking and debug logging for index access
Tests:
- Access list elements by index (multiple positions)
- Out-of-bounds index returns empty Value
- Index on non-list type returns empty Value
- Nested struct inside list element
- List of numbers (type-agnostic)
Usage example (ratelimit consuming grpc_field_extraction):
metadata_key:
key: envoy.filters.http.grpc_field_extraction
path:
- key: tenant_id
- index: 0 # unwrap the ListValue
Out-of-bounds index returns empty Value (same behavior as missing key).
Signed-off-by: Yusof Ganji <yganji@salesforce.com>
2440967 to
2eedcd1
Compare
Thanks for the feedback @wbpcode! I've implemented your suggested approach - adding What ChangedMetadataKey now supports list element access: message PathSegment {
oneof segment {
string key = 1;
uint32 index = 2; // NEW: access ListValue elements
}
}Usage example (ratelimit consuming grpc_field_extraction): metadata_key:
key: envoy.filters.http.grpc_field_extraction
path:
- key: tenant_id
- index: 0 # unwrap the ListValueImplementation details:
All grpc_field_extraction code changes reverted. Committed as 48e52cb1ad. Optional Enhancement: Auto-unwrap Single-Element ListsOne ergonomic concern: every grpc_field_extraction consumer must add // In structValue() after path traversal:
if (val->kind_case() == Protobuf::Value::kListValue && val->list_value().values_size() == 1) {
return val->list_value().values(0); // Auto-unwrap
}Pros:
Cons:
Happy to add this if you think the ergonomics justify the magic. Otherwise, current implementation is ready to merge. |
|
ping @wbpcode for review |
metadataValue(Metadata*, MetadataKey&) was delegating to a non-existent 3-arg overload (vector<PathSegment> doesn't match vector<string> or MetadataKey params) - implement the lookup directly via structValue. Also cast KindCase to int for ENVOY_LOG_MISC, since fmt/spdlog can't format the enum directly. Signed-off-by: Yusof Ganji <yganji@salesforce.com>
| // Currently it is only supported to specify the key, i.e. field name, as one segment of a path. | ||
| // Supports both key-based segments (field names in a Struct) and index-based | ||
| // segments (element access in a ListValue). | ||
| message PathSegment { | ||
| oneof segment { | ||
| option (validate.required) = true; | ||
|
|
||
| // If specified, use the key to retrieve the value in a Struct. | ||
| string key = 1 [(validate.rules).string = {min_bytes: 1}]; | ||
|
|
||
| // If specified, use this index to retrieve a value from a ListValue. | ||
| uint32 index = 2; |
There was a problem hiding this comment.
Good catch — reverted. v2 is FROZEN, so it shouldn't gain the index field. v3 already has it, which is what the new MetadataKey code path uses.
| path_.push_back(seg.key()); | ||
| PathSegment path_seg; | ||
| if (seg.has_key()) { | ||
| path_seg.type_ = PathSegment::Type::Key; |
There was a problem hiding this comment.
I don't think this type_ make sense. If non-empty key is there, we should treat it as key, or then try the index.
There was a problem hiding this comment.
Agreed — removed PathSegment::Type entirely. Segment kind is now inferred from !key_.empty(). This is safe because the v3 proto's key oneof arm has (validate.rules).string = {min_bytes: 1}, so a key segment can never legitimately be empty.
wbpcode
left a comment
There was a problem hiding this comment.
Thanks for the update. I added some comments to this PR.
There was a problem hiding this comment.
I guess we should enhance this method to support const std::vector<PathSegment>& path?
And for the unused dead code, like
const Protobuf::Value& Metadata::structValue(const Protobuf::Struct& struct_value,
const std::vector<std::string>& path)
Please do a clean up.
There was a problem hiding this comment.
Done — structValue(Struct, vector<string>) now converts each string into a key-only PathSegment and delegates to structValue(Struct, vector<PathSegment>), so there's no more duplicated struct-walking logic. I kept the vector<string> overload's public signature since it's still used directly by transform.cc (and its tests) — but it's now a thin wrapper rather than a parallel implementation.
Address review feedback from wbpcode: - Revert the v2 metadata.proto change; v2 is FROZEN and must not gain new fields. v3 already carries the index field. - Drop PathSegment::Type; infer key-vs-index from whether key_ is non-empty (a key segment can never be empty per the proto's min_bytes: 1 validation rule). - Have structValue(Struct, vector<string>) delegate to structValue(Struct, vector<PathSegment>) instead of duplicating the struct-walking loop. Signed-off-by: Yusof Ganji <yganji@salesforce.com>
wbpcode
left a comment
There was a problem hiding this comment.
Thanks for the update. Sorry for the regression of the review comment!
| std::vector<PathSegment> segments; | ||
| segments.reserve(path.size()); | ||
| for (const auto& key : path) { | ||
| PathSegment segment; | ||
| segment.key_ = key; | ||
| segments.push_back(std::move(segment)); |
There was a problem hiding this comment.
This may may the code patch who calling structValue(Struct, vector<string>) be super slow. my initially thought is to refactor all call sites to use new MetadataKey rather then vector<string> and then remove the legacy vector<string> related code.
There was a problem hiding this comment.
But fine, let's revert this change and keep the previous version. We can do the clean up at follow up and needn't to make everything perfect in this PR.
There was a problem hiding this comment.
Reverted — restored the original standalone structValue(Struct, vector<string>) implementation, so the hot call sites keep their zero-allocation walk and there's no perf regression. Agreed on deferring the real cleanup (migrating callers to MetadataKey and removing the legacy vector<string> path) to a follow-up PR.
|
/wait |
Revert the delegation refactor of structValue(Struct, vector<string>) per review feedback: delegating through vector<PathSegment> allocated a temp vector + copied every key into a PathSegment on each call, regressing hot call sites (transform.cc et al.). Keep the original zero-allocation walk. The resulting duplication between the two structValue overloads is intentional for now; migrating callers to MetadataKey and removing the legacy vector<string> path is deferred to a follow-up per reviewer. Signed-off-by: Yusof Ganji <yganji@salesforce.com>
|
@wbpcode I addressed the comments, please take a look. Thank you! |
|
Seems the CI failures are real |
MetadataKey::path_ changed from vector<string> to vector<PathSegment>, but addSelectedHostKey() in the override_host load balancer still indexed path_ as strings, breaking the config_test build (and cascading to all CI checks). Use PathSegment::key_ when building the struct field map — this write-path only ever addresses struct fields, never list indices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Yusof Ganji <yganji@salesforce.com>
You're right, @wbpcode — thanks. Root cause: Could you re-authorize the CI run when you get a chance? It's stuck on the |
Commit Message:
The grpc extraction filter always writes dynamic metadata as a ListValue, even for singular scalar fields. The ratelimit filter's MetaDataAction calls .string_value() on whatever Value it finds; calling .string_value() on a ListValue returns "" (wrong oneof arm), causing populateDescriptor() to return false and silently disabling rate limiting for every gRPC request.
Add a ValueType enum to RequestFieldValueDisposition (LIST = default, STRING = opt-in). When STRING is set, the filter promotes list_value().values(0) to a StringValue before writing to metadata, making the value consumable by the ratelimit filter's metadata descriptor action without a Lua bridge.
LIST remains the default for backward compatibility. STRING is only correct when every segment of the field path is non-repeated; the proto comment documents the repeated-field undercounting hazard explicitly.
Co-Authored-By: Claude Sonnet 4.6 (1M context) noreply@anthropic.com
Additional Description:
gRPC Field Extraction ↔ Rate Limit Filter Incompatibility
envoy.filters.http.grpc_field_extractionandenvoy.filters.http.ratelimitareincompatible by default. When a gRPC field is extracted and the ratelimit filter
is configured to use it as a descriptor, rate limiting is silently disabled for every
request — the ratelimit service is never called.
This is not a configuration error. It is a type mismatch baked into both filters.
Root Cause
What
grpc_field_extractionwritesThe extraction filter uses the
proto_field_extractionlibrary'sExtractValue(),which always returns a
Protobuf::Valuewithkind = kListValueon success —even for singular scalar fields. For a field
tenant_id: "acme"the metadata entrywritten to
envoy.filters.http.grpc_field_extractionis:{ "tenant_id": ["acme"] } // ListValue wrapping a single StringValue elementWhat
ratelimitreadsMetaDataAction::populateDescriptorinsource/common/router/router_ratelimit.cc:241:metadataValue(...).string_value()calls the protobufstring_value()accessoron a
ListValue. ProtobufValueis aoneof— calling the wrong arm returns thezero value, which for strings is
"".The failure chain
grpc_field_extractionwritesListValue { values: ["acme"] }to dynamic metadataMetaDataAction::populateDescriptorcalls.string_value()on theListValue""(wrongoneofarm)metadata_string_valueis""default_value_check — also empty (no default configured)skip_if_absent_which isfalseby defaultpopulateDescriptorreturnsfalse— the entire rate limit check is abortedThe outcome is worse than "rate limiting doesn't fire." With
skip_if_absent: false(the default), rate limiting is completely disabled for every affected request.
With
skip_if_absent: truethe descriptor is silently skipped, which is less badbut still means the field has no effect on rate limiting.
Options Considered
Option A —
value_type: STRINGingrpc_field_extraction(implemented in this PR)Add a
value_typeenum field toRequestFieldValueDispositionin the filter'sproto. When set to
STRING, the filter promotes the firstListValueelement toa
StringValuebefore writing to metadata. The ratelimit filter then reads aStringValueand.string_value()returns the correct value.Config:
What gets written to metadata:
{ "tenant_id": "acme" } // StringValue — ratelimit reads this correctlyCompatibility matrix:
LIST(default)STRING(opt-in)ratelimitmetadata descriptorext_authzforwardingrbacpolicy matching%DYNAMIC_METADATA%Limitations:
STRINGmode takes onlyvalues(0), silently discarding all subsequent elements.This is correct for singular scalar fields (
tenant_id,key.display_name) butcauses rate-limit undercounting for repeated fields: a request touching N values
is charged only against the first value's bucket.
value_type: STRINGis correct if and only if every segment of thefield path is singular (non-repeated) in the proto definition.
ext_authz) and a string (forratelimit) are neededfrom the same field, a Lua bridge is required — see Option C.
Code locations:
api/envoy/extensions/filters/http/grpc_field_extraction/v3/config.protosource/extensions/filters/http/grpc_field_extraction/extractor_impl.ccsource/extensions/filters/http/grpc_field_extraction/extractor_impl.htest/extensions/filters/http/grpc_field_extraction/filter_test.ccOption B — Fix
MetaDataActionin the ratelimit filter to unwrapListValueModify
router_ratelimit.cc:242to checkkind_case()and unwrapvalues(0)whenthe value is a
ListValue:Would it break anything?
In practice, no. Any existing consumer hitting a
ListValuefrom a metadata fieldis already receiving
""and their rate limiting is already silently broken. Thereis nothing to regress.
Problems with this approach:
metadata: { descriptor_key: tenant_id }sees no signal that list-unwrappingis happening. The extraction filter has the proto context; the ratelimit filter
does not.
extraction semantics. The extraction filter is the right place to own value-type
decisions because it knows whether the field is singular or repeated.
values(0)silently drops the rest, same as Option A, but now without even a config signal
to warn the operator.
Option C — Ratelimit filter fan-out over
ListValueelementsA more ambitious extension: when
MetaDataActionsees aListValue, emit oneRateLimitDescriptorper element. For a request withtenant_ids: ["a", "b", "c"],three descriptors would be submitted, charging each bucket.
This would solve the repeated-field undercounting problem that both A and B
cannot address. But:
the ratelimit service wire protocol (lyft/ratelimit expects a specific number of
descriptors per call).
(deduplicate hits, etc.).
Verdict: Maybe a long-term solution for repeated-field rate limiting, but a
different and much larger feature. Out of scope for the current spike.
Option D — Lua bridge for dual-consumer use case
When the same extracted field must be consumed by both the ratelimit filter
(needs
StringValue) and another filter (needs the fullListValue):value_type: LIST(full list preserved).grpc_field_extractionthat reads the list and writesa separate string key for the ratelimit filter:
The ratelimit descriptor then points at namespace
ratelimit.extractedinstead ofenvoy.filters.http.grpc_field_extraction.When this is needed: Only when the same field must feed both ratelimit
(string) and ext_authz / RBAC / access logs (full list). For pure GRLS pipelines,
value_type: STRING(Option A) is sufficient.Field Path Compatibility Reference
STRING?tenant_idkey.display_namekey.namesupported_types.stringrepeated_supported_types.stringkeys.namekeysis repeatedkey.tagsSafe rule:
value_type: STRINGis correct iff every segment of the dot-separatedpath is declared
singular(notrepeated) in the proto definition.Risk Level: Low
Testing:
To validate the fix end-to-end, a local test environment was assembled with the patched Envoy binary running as a Docker container fronting a gRPC echo service, with a Lyft ratelimit (GRLS) instance backed by Redis. A test script
drives gRPC requests through the full filter chain — grpc_field_extraction (with value_type: STRING) followed by the ratelimit filter's metadata descriptor action — with no Lua filter present. The script verifies that early requests
succeed and that once the configured limit is reached the ratelimit filter correctly rejects subsequent requests, confirming that the extracted field value reached GRLS and was acted on. A secondary check queries Redis directly to
confirm GRLS wrote a counter key for the descriptor, ruling out any false-positive from failure_mode_deny: false silently passing requests through. As a negative control, the same test was run against an unpatched Envoy with the
default LIST extraction mode, where all requests succeed, GRLS receives no ShouldRateLimit calls, and Redis remains empty — confirming the incompatibility is the default behavior and the fix is what enables the integration.
Docs Changes: N/A
Release Notes: Added changelog fragment.
Platform Specific Features: None