-
Notifications
You must be signed in to change notification settings - Fork 131
docs: add adr for targeting metadata #2013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| --- | ||
| # Valid statuses: draft | proposed | rejected | accepted | superseded | ||
| status: draft | ||
| author: Parth Suthar | ||
| created: 2026-07-30 | ||
| updated: 2026-07-30 | ||
| --- | ||
|
|
||
| # Per-evaluation metadata from targeting rules | ||
|
|
||
| Let a targeting rule return `{ "variant": "<key>", "metadata": { ... } }` in place of a plain variant string, so the _branch that fired_ can annotate the evaluation with a rule id or reason. | ||
| Existing string returns are unchanged. | ||
|
|
||
| ## Background | ||
|
|
||
| An evaluation today returns `value`, `variant`, `reason`, and `metadata`. | ||
| `reason` is a coarse enum (`TARGETING_MATCH`, `DEFAULT`, `STATIC`, …); `metadata` carries only the static blocks defined at flag-set and flag level. | ||
| Neither answers the debugging question we hit most often: **which branch of the targeting expression fired?** | ||
|
|
||
| For a nested `if` / `and` / `or` tree the resolver just returns the winning variant key. | ||
| Two rules landing on the same variant are indistinguishable in the response — the only way to know _why_ is to fetch the flag config and re-run the logic against the same context by hand. | ||
| Encoding the branch identity into the variant key (`"clubs-eu-rollout-a"`) is the workaround, and it pollutes the variant space with debug info that consumers then have to parse back out. | ||
|
|
||
| The plumbing to carry metadata already exists end to end: `AnyValue.Metadata` (`core/pkg/evaluator/ievaluator.go`) is `map[string]interface{}`, threaded through every resolver path and out through gRPC and OFREP as evaluation metadata that OpenFeature SDKs surface as `flagMetadata`. | ||
| The only thing missing is a way for a _rule branch_ to contribute to it. | ||
|
|
||
| ## Proposal | ||
|
|
||
| Extend `definitions.primitive` in `schemas/json/targeting.json` with a tagged-object return shape: | ||
|
|
||
| ```json | ||
| { | ||
| "type": "object", | ||
| "required": ["variant"], | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "variant": { "type": "string" }, | ||
| "metadata": { | ||
| "$ref": "https://flagd.dev/schema/v0/flags.json#/definitions/metadata" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Reusing the existing `metadata` definition keeps values restricted to `string | number | boolean`, matching flag and flag-set metadata. | ||
|
|
||
| In `evaluateVariant` (`core/pkg/evaluator/json.go`), replace the current string-strip of the JsonLogic result with a typed decode: | ||
|
|
||
| - string → variant key, as today | ||
| - object with `variant` field → use that as the variant key; shallow-merge `metadata` into the metadata already being returned | ||
| - anything else → `PARSE_ERROR`, same as an unrecognized return today | ||
|
Comment on lines
+47
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
echo "== JsonLogic module declaration =="
rg -n 'github.com/diegoholiveira/jsonlogic' . \
--glob 'go.mod' \
--glob 'go.sum' || true
echo "== evaluator implementation and tests =="
rg -n -C 8 'evaluateVariant|jsonlogic|PARSE_ERROR' core \
--glob '*.go'Repository: open-feature/flagd Length of output: 24028 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
echo "== current evaluator conversion path =="
sed -n '326,420p' core/pkg/evaluator/json.go
echo "== evaluator test files and relevant test declarations =="
git ls-files 'core/pkg/evaluator/*_test.go'
rg -n -C 5 'evaluateVariant|targeting|ParseErrorCode|PARSE_ERROR|metadata' core/pkg/evaluator --glob '*_test.go' | head -n 260
echo "== module declaration =="
sed -n '1,24p' core/go.mod
echo "== available local jsonlogic source, if any =="
find "${GOMODCACHE:-/nonexistent}" -path '*diegoholiveira*jsonlogic*' -type f 2>/dev/null | head -n 40 || trueRepository: open-feature/flagd Length of output: 22003 🌐 Web query:
💡 Result: The version v3.9.1 of the diegoholiveira/jsonlogic Go library, released on April 30, 2026, includes various internal fixes [1], but the specific phrase "operator-free object literal behavior" is not explicitly mentioned in the official release notes [1][2]. However, based on the implementation details within the library, JSON objects are processed according to the following logic: 1. Identification of Operators: The library identifies operators by evaluating the map keys [3][4]. If a map contains exactly one key that matches a registered operator, it executes that operation [3][5]. 2. Operator-Free Objects: In the context of this library, if a JSON object (a map) has more than one key, or if the key does not match any registered operators, it is treated as a primitive (or a raw object literal) and returned as-is rather than being interpreted as a JsonLogic command [3][4]. This behavior allows the library to distinguish between JSON structures intended as logic rules and standard JSON objects that should be passed through as data literals [3]. This mechanism serves as a way to "skip" evaluation for objects that do not conform to the expected Citations:
🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
base='https://raw.githubusercontent.com/diegoholiveira/jsonlogic/v3.9.1'
echo "== upstream evaluator implementation =="
curl -fsSL "$base/jsonlogic.go" | nl -ba | sed -n '1,260p'
echo "== upstream validator and operation handling =="
for file in validator.go operation.go; do
echo "--- $file ---"
curl -fsSL "$base/$file" | nl -ba | sed -n '1,240p'
done
echo "== upstream tests mentioning object literals, if, or Apply =="
for file in jsonlogic_test.go evaluator_test.go validator_test.go; do
body="$(curl -fsSL "$base/$file" 2>/dev/null || true)"
if [ -n "$body" ]; then
echo "--- $file ---"
printf '%s\n' "$body" | rg -n -C 4 'object|literal|Apply|if|map|variant' | head -n 220 || true
fi
doneRepository: open-feature/flagd Length of output: 309 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
base='https://raw.githubusercontent.com/diegoholiveira/jsonlogic/v3.9.1'
echo "== upstream evaluator implementation =="
curl -fsSL "$base/jsonlogic.go" | sed -n '1,280p'
echo "== upstream validator and operation handling =="
for file in validator.go operation.go; do
echo "--- $file ---"
curl -fsSL "$base/$file" | sed -n '1,260p'
done
echo "== upstream repository file list =="
curl -fsSL 'https://api.github.com/repos/diegoholiveira/jsonlogic/git/trees/v3.9.1?recursive=1' |
grep -o '"path": "[^"]*"' |
grep -E 'test|jsonlogic.go|validator.go|operation.go' |
head -n 100Repository: open-feature/flagd Length of output: 11512 Add compatibility tests for tagged objects.
🤖 Prompt for AI Agents |
||
|
|
||
| Merge precedence, lowest → highest, so more specific wins: | ||
| flag-set metadata → flag metadata → **rule-returned metadata**. | ||
|
|
||
| Example — chained `if` (adapted from the `chainable-conditions` playground scenario). Each branch now tags the evaluation with the rule that fired: | ||
|
|
||
| ```json | ||
| { | ||
| "acceptable-feature-stability": { | ||
| "state": "ENABLED", | ||
| "defaultVariant": "ga", | ||
| "variants": { | ||
| "alpha": "alpha", | ||
| "beta": "beta", | ||
| "ga": "ga" | ||
| }, | ||
| "targeting": { | ||
| "if": [ | ||
| { "===": [{ "var": "customerId" }, "customer-A"] }, | ||
| { | ||
| "variant": "alpha", | ||
| "metadata": { "rule": "customer-A-allowlist" } | ||
| }, | ||
| { "in": [{ "var": "customerId" }, ["customer-B1", "customer-B2"]] }, | ||
| { | ||
| "variant": "beta", | ||
| "metadata": { "rule": "beta-cohort" } | ||
| }, | ||
| { | ||
| "variant": "ga", | ||
| "metadata": { "rule": "ga-default" } | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Three callers, three variants, three different `metadata.rule` values in the evaluation result — no config lookup needed. | ||
|
|
||
| Nested rules work the same way — the branch that produces the return value is the one whose metadata rides through (adapted from `enable-by-locale` + `gradual-rollout` combined): | ||
|
|
||
| ```json | ||
| { | ||
| "targeting": { | ||
| "if": [ | ||
| { "==": [{ "var": "locale" }, "en-US"] }, | ||
| { | ||
| "if": [ | ||
| { "fractional": [{ "var": "targetingKey" }, ["on", 10], ["off", 90]] }, | ||
| { | ||
| "variant": "on", | ||
| "metadata": { "rule": "us-10pct-rollout" } | ||
| }, | ||
| { | ||
| "variant": "off", | ||
| "metadata": { "rule": "us-holdback" } | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "variant": "off", | ||
| "metadata": { "rule": "non-us-off" } | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| A US caller who buckets into the 10% cohort sees `metadata.rule=us-10pct-rollout`; the same variant `on` reached via any other path would carry a different `rule` value. The 90% US holdback and the non-US off case both serve `off` but are distinguishable in telemetry. | ||
|
|
||
| Note the `fractional` bucket itself still resolves to a plain variant string internally — see the fractional caveat in Open Questions. | ||
|
|
||
| ### Telemetry win | ||
|
|
||
| `flagMetadata` is already emitted on OpenFeature evaluation events and picked up by hooks (OpenTelemetry, logging, custom exporters). | ||
| Once rule metadata rides that channel, every downstream tool gets rule-level attribution _for free_: | ||
|
|
||
| - OTel spans / evaluation events carry a `rule` (or whatever key the config author picked) attribute — filter and group by rule id in traces and logs without touching application code. | ||
| - A/B and experimentation dashboards can bucket by rule id emitted by flagd, instead of re-computing which rule fired from raw context. | ||
| - Debug logs on the caller side get an immediate "why" alongside the "what" — variant plus rule id in the same log line, no config lookup. | ||
|
|
||
| This turns targeting attribution from a config-diving exercise into a queryable dimension anywhere `flagMetadata` already flows. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - **Good** — direct answer to "which branch fired" without overloading the variant key; no wire-format changes; every SDK already surfaces `flagMetadata`. | ||
| - **Good** — fully backwards compatible; string returns are unchanged byte-for-byte. | ||
| - **Bad** — in-process implementations (Java, JS, Kotlin, Python, .NET) each need the same object-return handling to stay conformant. Covered the usual way — a new suite in [flagd-testbed](https://github.com/open-feature/flagd-testbed). | ||
|
|
||
| ## Open questions | ||
|
|
||
| - **Fractional buckets.** `fractional` returns a variant string directly, so it can't tag the picked bucket with metadata without a separate operator extension (e.g. an optional third element per weight tuple). Proposal: defer — ship the base object-return shape first. | ||
| - **JsonLogic literal-object semantics.** We rely on the current Go engine (`github.com/diegoholiveira/jsonlogic`) treating an object with no operator keys as a data literal at return position. Pin with an integration test so a future engine swap doesn't regress it. | ||
| - **Metadata size.** No metadata field is capped today; consistency says leave uncapped, but a soft warning at config load is cheap if reviewers want it. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Document the migration and configuration-version contract before accepting this ADR.
When an older provider receives the new object return shape, it may reject the configuration or return
PARSE_ERROR. Existing string configurations remain compatible, but the new configuration format is not forward-compatible with older providers.Document the upgrade order, unsupported-provider behavior, and whether configuration requests carry a schema or feature version. Do not describe this as fully backwards compatible until mixed-version installations have a defined rollout path.
Also applies to: 47-51, 138-140
🤖 Prompt for AI Agents