fix(ingestion): resolve topic sample values against unwrapped messages - #30894
Conversation
Auto Classification on a messaging service sampled every field as null and
therefore never applied a PII tag. The schema parsers wrap a topic schema in a
root RECORD named after the schema, so the sampler builds column paths like
Customer.email, while the message on the wire is {"email": ...} with no such
wrapper. Every lookup missed.
The column names cannot change: AutoClassificationProcessor._find_column_by_dotted_path
matches on exactly that dotted scheme and is shared with database and storage
entities. So the fix is confined to value resolution, which now retries the path
with the schema root stripped after trying the full path first, leaving a
genuinely wrapped message resolving as before.
The integration fixture is why this shipped. It hand-built a flat schemaFields
list, a shape the connector cannot produce, so the flat lookup succeeded and the
tests passed. It now derives the fields from the real JSON schema parser, and the
assertions walk the tree instead of the top level.
Also stop requiring a processor block in an auto-classification workflow. Nothing
is configurable there for messaging or storage, so it is routinely omitted, and
SamplerProcessor died on it with a bare AttributeError. An empty inner config hit
the same wall one line later.
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
There was a problem hiding this comment.
Pull request overview
This PR fixes messaging-topic auto-classification sampling by resolving field values against both the schema-wrapped dotted path (e.g., Customer.email) and the unwrapped on-the-wire message shape (e.g., {"email": ...}), and updates tests to reflect the real parsed schema shape produced by the JSON schema parser.
Changes:
- Update messaging sampler value resolution to try the full dotted path first, then a root-stripped variant for unwrapped messages.
- Make
SamplerProcessortolerate missing/emptyprocessorconfig blocks (common for messaging/storage auto-classification). - Strengthen unit/integration tests to use real schema parsing and to traverse nested schema trees when asserting tags.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| ingestion/src/metadata/sampler/messaging/sampler.py | Adds root-tolerant value resolution for messaging samples (wrapped vs unwrapped messages). |
| ingestion/src/metadata/sampler/processor.py | Guards optional processor block / empty processor.config when building profiler_config. |
| ingestion/tests/unit/sampler/test_sampler_processor_config.py | New unit test ensuring SamplerProcessor construction works without processor config. |
| ingestion/tests/unit/sampler/test_messaging_sampler.py | New unit tests for dotted-path column naming and message value resolution across schema/message shapes. |
| ingestion/tests/integration/auto_classification/messaging/conftest.py | Integration fixture now derives schemaFields from the real JSON schema parser and adds helpers to traverse nested fields. |
| ingestion/tests/integration/auto_classification/messaging/test_kafka_classification.py | Assertions updated to find leaf fields in nested schemas and validate tags across all leaves. |
| ingestion/tests/integration/auto_classification/messaging/test_redpanda_classification.py | Updates fixture usage to include schema text and use shared nested-field lookup helper. |
Suppressed comments (1)
ingestion/src/metadata/sampler/messaging/sampler.py:115
_resolvetreats a resolved value ofNonethe same as a missing path, because_walkreturnsNonefor both cases. This can cause incorrect fallbacks: a genuinely wrapped message like{ "Order": { "email": null }, "email": "unwrapped@example.com" }would resolveOrder.emailto the unwrapped value, even though the full path exists and should win (per the docstring). Use a sentinel to distinguish “missing” from “present but null”, and only fallback when the path is actually missing.
def _walk(msg: dict, dotted: str) -> object:
"""Walk a dotted path into nested dicts, returning None when absent."""
cur: object = msg
for part in dotted.split("."):
if isinstance(cur, dict):
… sibling
Review feedback. _walk returned None both for an absent path and for a field
explicitly set to null, so a wrapped message carrying {"Order": {"email": null}}
alongside a top-level "email" fell through to the root-stripped candidate and
reported the sibling's value.
_walk now returns a MISSING sentinel, and only an absent path falls through. A
field the producer set to null is the schema-correct answer and is reported as
null.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ingestion/src/metadata/sampler/messaging/sampler.py:138
_resolvecurrently falls back to the root-stripped path whenever the full dotted path is absent. This means that if a message is actually wrapped (root key exists) but the nested field is missing (e.g.,{"Order": {}}plus a top-levelemail), the sampler will incorrectly pick the sibling top-level value forOrder.email, which contradicts the intended "wrapped message keeps winning" behavior described in the docstring.
for candidate in (dotted, dotted.split(".", 1)[-1]):
value = MessagingSampler._walk(msg, candidate)
# Only an absent path falls through: a field explicitly set to null is
# the schema-correct answer and must not inherit a same-named sibling.
if value is not MISSING:
return value
return None
✅ Playwright Results — workflow succeededValidated commit ✅ 105 passed · ❌ 0 failed · 🟡 0 flaky · ⏭️ 3 skipped · 🧰 0 lifecycle flaky PerformanceBlocking targets: ✅ met · Optimization targets: 🟡 in progress Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting. 🕒 Full workflow signal wall (to summary) 47m 51s ⏱️ Max setup 3m 1s · max shard execution 10m 56s · max shard-job elapsed before upload 15m 58s · reporting 4s 🌐 212.07 requests/attempt · 1.78 app boots/UI scenario · 0.00% common-shard skew Optimization targets still in progress:
How to debug locally# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip # view trace |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
ingestion/src/metadata/sampler/messaging/sampler.py:138
_resolve()always tries two candidates, but for flat schemas (no dot indotted) the second candidate is identical to the first, causing redundant lookups for every column/message. This is a measurable (though small) performance regression for common flat-topic schemas.
for candidate in (dotted, dotted.split(".", 1)[-1]):
value = MessagingSampler._walk(msg, candidate)
# Only an absent path falls through: a field explicitly set to null is
# the schema-correct answer and must not inherit a same-named sibling.
if value is not MISSING:
return value
return None
ingestion/tests/unit/sampler/test_messaging_sampler.py:70
- The helper
_topic()is annotated asfields: list[FieldModel]but the test calls it withNone(@pytest.mark.parametrize("fields", [[], None])). This makes the type hint internally inconsistent and can trip type-checkers/static analysis.
def _topic(fields: list[FieldModel]) -> Topic:
return Topic(
id=uuid.uuid4(),
name="om_orders_avro",
partitions=1,
service=EntityReference(id=uuid.uuid4(), type="messagingService"),
messageSchema=MessageSchema(schemaFields=fields),
|
Code Review ✅ Approved 1 resolved / 1 findingsFixes Kafka and Redpanda PII auto-classification by updating topic sample value resolution to fall back against unwrapped messages and handles missing processor configurations, addressing the _resolve fallback conflates explicit null with missing field finding. ✅ 1 resolved✅ Edge Case: _resolve fallback conflates explicit null with missing field
OptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Gitar | Powered by Gitar — free for open source |



What
Auto Classification on a messaging service samples every field as
nulland therefore never applies a PII tag. Reproduced against a live Kafka source: the stored sample is literally{"Customer": {"customer_id": null, "full_name": null, "email": null, "ssn": null, ...}}with no tag on any field. This affects every Avro and JSON Schema topic, so PII detection on Kafka and Redpanda has not worked since it shipped in #28446.
Why
The schema parsers wrap a topic schema in a root RECORD named after the schema, so
MessagingSampler._flatten_fieldbuilds column paths likeCustomer.email. The message on the wire is{"email": ...}with no such wrapper, so_resolvewalkedmsg["Customer"]["email"]and returnedNonefor every field.The column names cannot change.
AutoClassificationProcessor._find_column_by_dotted_pathmatches on exactly that dotted scheme — its docstring says so — and it is shared with database and storage entities, attaching the tag viacolumn.fullyQualifiedName. Renaming the columns would break tagging for every entity type.The fix is therefore confined to value resolution: try the full path first, then the path with the schema root stripped. A genuinely wrapped message keeps resolving as it does today.
Why it was not caught
The integration fixture hand-built a flat
schemaFieldslist and pushed it straight into aCreateTopicRequest, bypassing the connector. That shape cannot come out of ingestion —yield_topicruns the registry schema throughparse_avro_schema/parse_json_schema, both of which emit a root RECORD. With a flat list the lookup succeeded and the assertions passed.The fixture now derives its fields from the real JSON schema parser, so it tracks whatever the parser produces, and the assertions walk the tree rather than the top level.
Also
SamplerProcessordereferencedconfig.processorunconditionally. Messaging and storage auto-classification have nothing configurable there, so the block is routinely omitted, and the workflow died at startup with a bareAttributeError: 'NoneType' object has no attribute 'model_dump'. An empty innerconfig:hitValidationErrorone line later. Both are now accepted.The same unguarded line exists in
ProfilerProcessor, but every profiler config supplies a processor in practice and there is no unit coverage there, so it is left alone rather than changed speculatively.Scope
_resolveand_flatten_fieldare referenced only byMessagingSampler.fetch_sample_data. Its subclasses are Kafka, Redpanda (via Kafka), and the Kinesis and PubSub stubs. Database and storage samplers are separate classes and are untouched.New unit tests cover the nested schema, the wrapped-message case, both shapes present at once, the flat schema, an absent field, and a topic with no schema fields. They fail on
mainand pass here.